From 94d212714b6f3ca6f8d660271d701fa2a9eb7f9c Mon Sep 17 00:00:00 2001
From: westey <164392973+westey-m@users.noreply.github.com>
Date: Thu, 20 Jun 2024 11:17:24 +0100
Subject: [PATCH 1/6] Adding qdrant vector record store implementation.
---
dotnet/Directory.Packages.props | 1 +
.../Connectors.Memory.Qdrant.csproj | 1 +
.../QdrantRecordMapperType.cs | 21 +
.../QdrantVectorRecordStore.cs | 388 ++++++++++++++++++
.../QdrantVectorRecordStoreOptions.cs | 48 +++
.../QdrantVectorStoreRecordMapper.cs | 299 ++++++++++++++
.../QdrantVectorStoreRecordMapperOptions.cs | 27 ++
.../Qdrant/QdrantVectorRecordStoreTests.cs | 282 +++++++++++++
.../QdrantVectorStoreCollectionFixture.cs | 10 +
.../Memory/Qdrant/QdrantVectorStoreFixture.cs | 325 +++++++++++++++
.../IntegrationTests/IntegrationTests.csproj | 1 +
11 files changed, 1403 insertions(+)
create mode 100644 dotnet/src/Connectors/Connectors.Memory.Qdrant/QdrantRecordMapperType.cs
create mode 100644 dotnet/src/Connectors/Connectors.Memory.Qdrant/QdrantVectorRecordStore.cs
create mode 100644 dotnet/src/Connectors/Connectors.Memory.Qdrant/QdrantVectorRecordStoreOptions.cs
create mode 100644 dotnet/src/Connectors/Connectors.Memory.Qdrant/QdrantVectorStoreRecordMapper.cs
create mode 100644 dotnet/src/Connectors/Connectors.Memory.Qdrant/QdrantVectorStoreRecordMapperOptions.cs
create mode 100644 dotnet/src/IntegrationTests/Connectors/Memory/Qdrant/QdrantVectorRecordStoreTests.cs
create mode 100644 dotnet/src/IntegrationTests/Connectors/Memory/Qdrant/QdrantVectorStoreCollectionFixture.cs
create mode 100644 dotnet/src/IntegrationTests/Connectors/Memory/Qdrant/QdrantVectorStoreFixture.cs
diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props
index 38b249cc229a..5a1ba6b7fef8 100644
--- a/dotnet/Directory.Packages.props
+++ b/dotnet/Directory.Packages.props
@@ -92,6 +92,7 @@
+
diff --git a/dotnet/src/Connectors/Connectors.Memory.Qdrant/Connectors.Memory.Qdrant.csproj b/dotnet/src/Connectors/Connectors.Memory.Qdrant/Connectors.Memory.Qdrant.csproj
index da803a71b52a..f06d269cdabc 100644
--- a/dotnet/src/Connectors/Connectors.Memory.Qdrant/Connectors.Memory.Qdrant.csproj
+++ b/dotnet/src/Connectors/Connectors.Memory.Qdrant/Connectors.Memory.Qdrant.csproj
@@ -20,6 +20,7 @@
+
diff --git a/dotnet/src/Connectors/Connectors.Memory.Qdrant/QdrantRecordMapperType.cs b/dotnet/src/Connectors/Connectors.Memory.Qdrant/QdrantRecordMapperType.cs
new file mode 100644
index 000000000000..cb8f7bf8b14c
--- /dev/null
+++ b/dotnet/src/Connectors/Connectors.Memory.Qdrant/QdrantRecordMapperType.cs
@@ -0,0 +1,21 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using Qdrant.Client.Grpc;
+
+namespace Microsoft.SemanticKernel.Connectors.Qdrant;
+
+///
+/// The types of mapper supported by .
+///
+public enum QdrantRecordMapperType
+{
+ ///
+ /// Use the default mapper that is provided by the semantic kernel SDK that uses json as an intermediary to allows automatic mapping to a wide variety of types.
+ ///
+ Default,
+
+ ///
+ /// Use a custom mapper between and the data model.
+ ///
+ QdrantPointStructCustomMapper
+}
diff --git a/dotnet/src/Connectors/Connectors.Memory.Qdrant/QdrantVectorRecordStore.cs b/dotnet/src/Connectors/Connectors.Memory.Qdrant/QdrantVectorRecordStore.cs
new file mode 100644
index 000000000000..a1aa76a422cd
--- /dev/null
+++ b/dotnet/src/Connectors/Connectors.Memory.Qdrant/QdrantVectorRecordStore.cs
@@ -0,0 +1,388 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Grpc.Core;
+using Microsoft.SemanticKernel.Memory;
+using Qdrant.Client;
+using Qdrant.Client.Grpc;
+
+namespace Microsoft.SemanticKernel.Connectors.Qdrant;
+
+///
+/// Service for storing and retrieving vector records, that uses Qdrant as the underlying storage.
+///
+/// The data model to use for adding, updating and retrieving data from storage.
+public sealed class QdrantVectorRecordStore : IVectorRecordStore, IVectorRecordStore
+ where TRecord : class
+{
+ /// Qdrant client that can be used to manage the collections and points in a Qdrant store.
+ private readonly QdrantClient _qdrantClient;
+
+ /// Optional configuration options for this class.
+ private readonly QdrantVectorRecordStoreOptions _options;
+
+ /// A mapper to use for converting between qdrant point and consumer models.
+ private readonly IVectorStoreRecordMapper _mapper;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Qdrant client that can be used to manage the collections and points in a Qdrant store.
+ /// Optional configuration options for this class.
+ ///
+ ///
+ public QdrantVectorRecordStore(QdrantClient qdrantClient, QdrantVectorRecordStoreOptions? options = null)
+ {
+ // Verify.
+ Verify.NotNull(qdrantClient);
+
+ // Assign.
+ this._qdrantClient = qdrantClient;
+ this._options = options ?? new QdrantVectorRecordStoreOptions();
+
+ // Assign Mapper.
+ if (this._options.MapperType == QdrantRecordMapperType.QdrantPointStructCustomMapper)
+ {
+ // Custom Mapper.
+ if (this._options.PointStructCustomMapper is null)
+ {
+ throw new ArgumentException($"The {nameof(QdrantVectorRecordStoreOptions.PointStructCustomMapper)} option needs to be set if a {nameof(QdrantVectorRecordStoreOptions.MapperType)} of {nameof(QdrantRecordMapperType.QdrantPointStructCustomMapper)} has been chosen.", nameof(options));
+ }
+
+ this._mapper = this._options.PointStructCustomMapper;
+ }
+ else
+ {
+ // Default Mapper.
+ this._mapper = new QdrantVectorStoreRecordMapper(new QdrantVectorStoreRecordMapperOptions
+ {
+ HasNamedVectors = this._options.HasNamedVectors,
+ VectorStoreRecordDefinition = this._options.VectorStoreRecordDefinition
+ });
+ }
+ }
+
+ ///
+ public async Task GetAsync(ulong key, GetRecordOptions? options = null, CancellationToken cancellationToken = default)
+ {
+ Verify.NotNull(key);
+
+ var retrievedPoints = await this.GetBatchAsync([key], options, cancellationToken).ToListAsync(cancellationToken).ConfigureAwait(false);
+ return retrievedPoints[0];
+ }
+
+ ///
+ public async Task GetAsync(Guid key, GetRecordOptions? options = null, CancellationToken cancellationToken = default)
+ {
+ Verify.NotNull(key);
+
+ var retrievedPoints = await this.GetBatchAsync([key], options, cancellationToken).ToListAsync(cancellationToken).ConfigureAwait(false);
+ return retrievedPoints[0];
+ }
+
+ ///
+ public IAsyncEnumerable GetBatchAsync(IEnumerable keys, GetRecordOptions? options = default, CancellationToken cancellationToken = default)
+ {
+ return this.GetBatchByPointIdAsync(keys, key => new PointId { Num = key }, options, cancellationToken);
+ }
+
+ ///
+ public IAsyncEnumerable GetBatchAsync(IEnumerable keys, GetRecordOptions? options = default, CancellationToken cancellationToken = default)
+ {
+ return this.GetBatchByPointIdAsync(keys, key => new PointId { Uuid = key.ToString("D") }, options, cancellationToken);
+ }
+
+ ///
+ public Task DeleteAsync(ulong key, DeleteRecordOptions? options = null, CancellationToken cancellationToken = default)
+ {
+ Verify.NotNull(key);
+
+ var collectionName = this.ChooseCollectionName(options?.CollectionName);
+ return RunOperationAsync(
+ collectionName,
+ "Delete",
+ () => this._qdrantClient.DeleteAsync(
+ collectionName,
+ key,
+ wait: true,
+ cancellationToken: cancellationToken));
+ }
+
+ ///
+ public Task DeleteAsync(Guid key, DeleteRecordOptions? options = null, CancellationToken cancellationToken = default)
+ {
+ Verify.NotNull(key);
+
+ var collectionName = this.ChooseCollectionName(options?.CollectionName);
+ return RunOperationAsync(
+ collectionName,
+ "Delete",
+ () => this._qdrantClient.DeleteAsync(
+ collectionName,
+ key,
+ wait: true,
+ cancellationToken: cancellationToken));
+ }
+
+ ///
+ public Task DeleteBatchAsync(IEnumerable keys, DeleteRecordOptions? options = default, CancellationToken cancellationToken = default)
+ {
+ Verify.NotNull(keys);
+
+ var collectionName = this.ChooseCollectionName(options?.CollectionName);
+ return RunOperationAsync(
+ collectionName,
+ "Delete",
+ () => this._qdrantClient.DeleteAsync(
+ collectionName,
+ keys.ToList(),
+ wait: true,
+ cancellationToken: cancellationToken));
+ }
+
+ ///
+ public Task DeleteBatchAsync(IEnumerable keys, DeleteRecordOptions? options = default, CancellationToken cancellationToken = default)
+ {
+ Verify.NotNull(keys);
+
+ var collectionName = this.ChooseCollectionName(options?.CollectionName);
+ return RunOperationAsync(
+ collectionName,
+ "Delete",
+ () => this._qdrantClient.DeleteAsync(
+ collectionName,
+ keys.ToList(),
+ wait: true,
+ cancellationToken: cancellationToken));
+ }
+
+ ///
+ public async Task UpsertAsync(TRecord record, UpsertRecordOptions? options = default, CancellationToken cancellationToken = default)
+ {
+ Verify.NotNull(record);
+
+ // Create options.
+ var collectionName = this.ChooseCollectionName(options?.CollectionName);
+
+ // Create point from record.
+ var pointStruct = RunModelConversion(
+ collectionName,
+ "Upsert",
+ () => this._mapper.MapFromDataToStorageModel(record));
+
+ // Upsert.
+ await RunOperationAsync(
+ collectionName,
+ "Upsert",
+ () => this._qdrantClient.UpsertAsync(collectionName, [pointStruct], true, cancellationToken: cancellationToken)).ConfigureAwait(false);
+ return pointStruct.Id.Num;
+ }
+
+ ///
+ async Task IVectorRecordStore.UpsertAsync(TRecord record, UpsertRecordOptions? options, CancellationToken cancellationToken)
+ {
+ Verify.NotNull(record);
+
+ // Create options.
+ var collectionName = this.ChooseCollectionName(options?.CollectionName);
+
+ // Create point from record.
+ var pointStruct = RunModelConversion(
+ collectionName,
+ "Upsert",
+ () => this._mapper.MapFromDataToStorageModel(record));
+
+ // Upsert.
+ await RunOperationAsync(
+ collectionName,
+ "Upsert",
+ () => this._qdrantClient.UpsertAsync(collectionName, [pointStruct], true, cancellationToken: cancellationToken)).ConfigureAwait(false);
+ return Guid.Parse(pointStruct.Id.Uuid);
+ }
+
+ ///
+ public async IAsyncEnumerable UpsertBatchAsync(IEnumerable records, UpsertRecordOptions? options = default, [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ Verify.NotNull(records);
+
+ // Create Options
+ var collectionName = this.ChooseCollectionName(options?.CollectionName);
+
+ // Create points from records.
+ var pointStructs = RunModelConversion(
+ collectionName,
+ "Upsert",
+ () => records.Select(this._mapper.MapFromDataToStorageModel).ToList());
+
+ // Upsert.
+ await RunOperationAsync(
+ collectionName,
+ "Upsert",
+ () => this._qdrantClient.UpsertAsync(collectionName, pointStructs, true, cancellationToken: cancellationToken)).ConfigureAwait(false);
+
+ foreach (var pointStruct in pointStructs)
+ {
+ yield return pointStruct.Id.Num;
+ }
+ }
+
+ ///
+ async IAsyncEnumerable IVectorRecordStore.UpsertBatchAsync(IEnumerable records, UpsertRecordOptions? options, [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ Verify.NotNull(records);
+
+ // Create Options
+ var collectionName = this.ChooseCollectionName(options?.CollectionName);
+
+ // Create points from records.
+ var pointStructs = RunModelConversion(
+ collectionName,
+ "Upsert",
+ () => records.Select(this._mapper.MapFromDataToStorageModel).ToList());
+
+ // Upsert.
+ await RunOperationAsync(
+ collectionName,
+ "Upsert",
+ () => this._qdrantClient.UpsertAsync(collectionName, pointStructs, true, cancellationToken: cancellationToken)).ConfigureAwait(false);
+
+ foreach (var pointStruct in pointStructs)
+ {
+ yield return Guid.Parse(pointStruct.Id.Uuid);
+ }
+ }
+
+ ///
+ /// Get the requested records from the Qdrant store using the provided keys.
+ ///
+ /// The keys of the points to retrieve.
+ /// Function to convert the provided keys to point ids.
+ /// The retrieval options.
+ /// The to monitor for cancellation requests. The default is .
+ /// The retrieved points.
+ private async IAsyncEnumerable GetBatchByPointIdAsync(
+ IEnumerable keys,
+ Func keyConverter,
+ GetRecordOptions? options,
+ [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ Verify.NotNull(keys);
+
+ // Create options.
+ var collectionName = this.ChooseCollectionName(options?.CollectionName);
+ var pointsIds = keys.Select(key => keyConverter(key)).ToArray();
+
+ // Retrieve data points.
+ var retrievedPoints = await RunOperationAsync(
+ collectionName,
+ "Retrieve",
+ () => this._qdrantClient.RetrieveAsync(collectionName, pointsIds, true, options?.IncludeVectors ?? false, cancellationToken: cancellationToken)).ConfigureAwait(false);
+
+ // Check that we found the required number of values.
+ if (retrievedPoints.Count != pointsIds.Length)
+ {
+ throw new VectorStoreOperationException("Record not found");
+ }
+
+ // Convert the retrieved points to the target data model.
+ foreach (var retrievedPoint in retrievedPoints)
+ {
+ var pointStruct = new PointStruct
+ {
+ Id = retrievedPoint.Id,
+ Vectors = retrievedPoint.Vectors,
+ Payload = { }
+ };
+
+ foreach (KeyValuePair payloadEntry in retrievedPoint.Payload)
+ {
+ pointStruct.Payload.Add(payloadEntry.Key, payloadEntry.Value);
+ }
+
+ yield return RunModelConversion(
+ collectionName,
+ "Retrieve",
+ () => this._mapper.MapFromStorageToDataModel(pointStruct, options));
+ }
+ }
+
+ ///
+ /// Choose the right collection name to use for the operation by using the one provided
+ /// as part of the operation options, or the default one provided at construction time.
+ ///
+ /// The collection name provided on the operation options.
+ /// The collection name to use.
+ private string ChooseCollectionName(string? operationCollectionName)
+ {
+ var collectionName = operationCollectionName ?? this._options.DefaultCollectionName;
+ if (collectionName is null)
+ {
+#pragma warning disable CA2208 // Instantiate argument exceptions correctly
+ throw new ArgumentException("Collection name must be provided in the operation options, since no default was provided at construction time.", "options");
+#pragma warning restore CA2208 // Instantiate argument exceptions correctly
+ }
+
+ return collectionName;
+ }
+
+ ///
+ /// Run the given operation and wrap any with ."/>
+ ///
+ /// The response type of the operation.
+ /// The name of the collection the operation is being run on.
+ /// The type of database operation being run.
+ /// The operation to run.
+ /// The result of the operation.
+ private static async Task RunOperationAsync(string collectionName, string operationName, Func> operation)
+ {
+ try
+ {
+ return await operation.Invoke().ConfigureAwait(false);
+ }
+ catch (RpcException ex)
+ {
+ var wrapperException = new VectorStoreOperationException("Call to vector store failed.", ex);
+
+ // Using Open Telemetry standard for naming of these entries.
+ // https://opentelemetry.io/docs/specs/semconv/attributes-registry/db/
+ wrapperException.Data.Add("db.system", "Qdrant");
+ wrapperException.Data.Add("db.collection.name", collectionName);
+ wrapperException.Data.Add("db.operation.name", operationName);
+
+ throw wrapperException;
+ }
+ }
+
+ ///
+ /// Run the given model conversion and wrap any exceptions with .
+ ///
+ /// The response type of the operation.
+ /// The name of the collection the operation is being run on.
+ /// The type of database operation being run.
+ /// The operation to run.
+ /// The result of the operation.
+ private static T RunModelConversion(string collectionName, string operationName, Func operation)
+ {
+ try
+ {
+ return operation.Invoke();
+ }
+ catch (Exception ex) when (ex is not VectorStoreRecordMappingException)
+ {
+ var wrapperException = new VectorStoreRecordMappingException("Failed to convert vector store record.", ex);
+
+ // Using Open Telemetry standard for naming of these entries.
+ // https://opentelemetry.io/docs/specs/semconv/attributes-registry/db/
+ wrapperException.Data.Add("db.system", "Qdrant");
+ wrapperException.Data.Add("db.collection.name", collectionName);
+ wrapperException.Data.Add("db.operation.name", operationName);
+
+ throw wrapperException;
+ }
+ }
+}
diff --git a/dotnet/src/Connectors/Connectors.Memory.Qdrant/QdrantVectorRecordStoreOptions.cs b/dotnet/src/Connectors/Connectors.Memory.Qdrant/QdrantVectorRecordStoreOptions.cs
new file mode 100644
index 000000000000..d3e568057976
--- /dev/null
+++ b/dotnet/src/Connectors/Connectors.Memory.Qdrant/QdrantVectorRecordStoreOptions.cs
@@ -0,0 +1,48 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using Microsoft.SemanticKernel.Memory;
+using Qdrant.Client.Grpc;
+
+namespace Microsoft.SemanticKernel.Connectors.Qdrant;
+
+///
+/// Options when creating a .
+///
+public sealed class QdrantVectorRecordStoreOptions
+ where TRecord : class
+{
+ ///
+ /// Gets or sets the default collection name to use.
+ /// If not provided here, the collection name will need to be provided for each operation or the operation will throw.
+ ///
+ public string? DefaultCollectionName { get; init; } = null;
+
+ ///
+ /// Gets or sets a value indicating whether the vectors in the store are named and multiple vectors are supported, or whether there is just a single unnamed vector per qdrant point.
+ /// Defaults to single vector per point.
+ ///
+ public bool HasNamedVectors { get; set; } = false;
+
+ ///
+ /// Gets or sets the choice of mapper to use when converting between the data model and the qdrant point.
+ ///
+ public QdrantRecordMapperType MapperType { get; init; } = QdrantRecordMapperType.Default;
+
+ ///
+ /// Gets or sets an optional custom mapper to use when converting between the data model and the qdrant point.
+ ///
+ ///
+ /// Set to to use this mapper."/>
+ ///
+ public IVectorStoreRecordMapper? PointStructCustomMapper { get; init; } = null;
+
+ ///
+ /// Gets or sets an optional record definition that defines the schema of the record type.
+ ///
+ ///
+ /// If not provided, the schema will be inferred from the record model class using reflection.
+ /// In this case, the record model properties must be annotated with the appropriate attributes to indicate their usage.
+ /// See , and .
+ ///
+ public VectorStoreRecordDefinition? VectorStoreRecordDefinition { get; init; } = null;
+}
diff --git a/dotnet/src/Connectors/Connectors.Memory.Qdrant/QdrantVectorStoreRecordMapper.cs b/dotnet/src/Connectors/Connectors.Memory.Qdrant/QdrantVectorStoreRecordMapper.cs
new file mode 100644
index 000000000000..29f8d9002cb2
--- /dev/null
+++ b/dotnet/src/Connectors/Connectors.Memory.Qdrant/QdrantVectorStoreRecordMapper.cs
@@ -0,0 +1,299 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using Microsoft.SemanticKernel.Memory;
+using Qdrant.Client.Grpc;
+
+namespace Microsoft.SemanticKernel.Connectors.Qdrant;
+
+///
+/// Mapper between a Qdrant record and the consumer data model that uses json as an intermediary to allow supporting a wide range of models.
+///
+/// The consumer data model to map to or from.
+internal sealed class QdrantVectorStoreRecordMapper : IVectorStoreRecordMapper
+ where TRecord : class
+{
+ /// A set of types that a key on the provided model may have.
+ private static readonly HashSet s_supportedKeyTypes =
+ [
+ typeof(ulong),
+ typeof(Guid)
+ ];
+
+ /// A set of types that data properties on the provided model may have.
+ private static readonly HashSet s_supportedDataTypes =
+ [
+ typeof(List),
+ typeof(List),
+ typeof(List),
+ typeof(List),
+ typeof(List),
+ typeof(List),
+ typeof(string),
+ typeof(int),
+ typeof(long),
+ typeof(double),
+ typeof(float),
+ typeof(bool),
+ typeof(int?),
+ typeof(long?),
+ typeof(double?),
+ typeof(float?),
+ typeof(bool?)
+ ];
+
+ /// A set of types that vectors on the provided model may have.
+ ///
+ /// While qdrant supports float32 and uint64, the api only supports float64, therefore
+ /// any float32 vectors will be converted to float64 before being sent to qdrant.
+ ///
+ private static readonly HashSet s_supportedVectorTypes =
+ [
+ typeof(ReadOnlyMemory),
+ typeof(ReadOnlyMemory?),
+ typeof(ReadOnlyMemory),
+ typeof(ReadOnlyMemory?)
+ ];
+
+ /// A list of property info objects that point at the payload properties in the current model, and allows easy reading and writing of these properties.
+ private readonly List _payloadPropertiesInfo = new();
+
+ /// A list of property info objects that point at the vector properties in the current model, and allows easy reading and writing of these properties.
+ private readonly List _vectorPropertiesInfo = new();
+
+ /// A property info object that points at the key property for the current model, allowing easy reading and writing of this property.
+ private readonly PropertyInfo _keyPropertyInfo;
+
+ /// Optional configuration options for this class.
+ private readonly QdrantVectorStoreRecordMapperOptions _options;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Optional options to use when doing the model conversion.
+ public QdrantVectorStoreRecordMapper(QdrantVectorStoreRecordMapperOptions? options)
+ {
+ this._options = options ?? new QdrantVectorStoreRecordMapperOptions();
+
+ // Enumerate public properties using configuration or attributes.
+ (PropertyInfo keyProperty, List dataProperties, List vectorProperties) properties;
+ if (this._options.VectorStoreRecordDefinition is not null)
+ {
+ properties = VectorStoreRecordPropertyReader.FindProperties(typeof(TRecord), this._options.VectorStoreRecordDefinition, supportsMultipleVectors: this._options.HasNamedVectors);
+ }
+ else
+ {
+ properties = VectorStoreRecordPropertyReader.FindProperties(typeof(TRecord), supportsMultipleVectors: this._options.HasNamedVectors);
+ }
+
+ // Validate property types and store for later use.
+ VectorStoreRecordPropertyReader.VerifyPropertyTypes([properties.keyProperty], s_supportedKeyTypes, "Key");
+ VectorStoreRecordPropertyReader.VerifyPropertyTypes(properties.dataProperties, s_supportedDataTypes, "Data");
+ VectorStoreRecordPropertyReader.VerifyPropertyTypes(properties.vectorProperties, s_supportedVectorTypes, "Vector");
+
+ this._keyPropertyInfo = properties.keyProperty;
+ this._payloadPropertiesInfo = properties.dataProperties;
+ this._vectorPropertiesInfo = properties.vectorProperties;
+ }
+
+ ///
+ public PointStruct MapFromDataToStorageModel(TRecord dataModel)
+ {
+ PointId pointId;
+ if (this._keyPropertyInfo.PropertyType == typeof(ulong))
+ {
+ var key = this._keyPropertyInfo.GetValue(dataModel) as ulong? ?? throw new VectorStoreRecordMappingException($"Missing key property {this._keyPropertyInfo.Name} on provided record of type {typeof(TRecord).FullName}.");
+ pointId = new PointId { Num = key };
+ }
+ else if (this._keyPropertyInfo.PropertyType == typeof(Guid))
+ {
+ var key = this._keyPropertyInfo.GetValue(dataModel) as Guid? ?? throw new VectorStoreRecordMappingException($"Missing key property {this._keyPropertyInfo.Name} on provided record of type {typeof(TRecord).FullName}.");
+ pointId = new PointId { Uuid = key.ToString("D") };
+ }
+ else
+ {
+ throw new VectorStoreRecordMappingException($"Unsupported key type {this._keyPropertyInfo.PropertyType.FullName} for key property {this._keyPropertyInfo.Name} on provided record of type {typeof(TRecord).FullName}.");
+ }
+
+ // Create point.
+ var pointStruct = new PointStruct
+ {
+ Id = pointId,
+ Vectors = new Vectors(),
+ Payload = { },
+ };
+
+ // Add point payload.
+ foreach (var payloadPropertyInfo in this._payloadPropertiesInfo)
+ {
+ var propertyName = VectorStoreRecordPropertyReader.GetSerializedPropertyName(payloadPropertyInfo);
+ var propertyValue = payloadPropertyInfo.GetValue(dataModel);
+ pointStruct.Payload.Add(propertyName, ConvertToGrpcFieldValue(propertyValue));
+ }
+
+ // Add vectors.
+ if (this._options.HasNamedVectors)
+ {
+ var namedVectors = new NamedVectors();
+ foreach (var vectorPropertyInfo in this._vectorPropertiesInfo)
+ {
+ var propertyName = VectorStoreRecordPropertyReader.GetSerializedPropertyName(vectorPropertyInfo);
+ var propertyValue = vectorPropertyInfo.GetValue(dataModel);
+ if (propertyValue is not null)
+ {
+ var castPropertyValue = (ReadOnlyMemory)propertyValue;
+ namedVectors.Vectors.Add(propertyName, castPropertyValue.ToArray());
+ }
+ }
+
+ pointStruct.Vectors.Vectors_ = namedVectors;
+ }
+ else
+ {
+ var vectorPropertyInfo = this._vectorPropertiesInfo.First();
+ if (vectorPropertyInfo.GetValue(dataModel) is ReadOnlyMemory floatROM)
+ {
+ pointStruct.Vectors.Vector = floatROM.ToArray();
+ }
+ else
+ {
+ throw new VectorStoreRecordMappingException($"Vector property {vectorPropertyInfo.Name} on provided record of type {typeof(TRecord).FullName} may not be null when not using named vectors.");
+ }
+ }
+
+ return pointStruct;
+ }
+
+ ///
+ public TRecord MapFromStorageToDataModel(PointStruct storageModel, GetRecordOptions? options = default)
+ {
+ // Get the key property name and value.
+ var keyPropertyName = VectorStoreRecordPropertyReader.GetSerializedPropertyName(this._keyPropertyInfo);
+ var keyPropertyValue = storageModel.Id.HasNum ? storageModel.Id.Num as object : storageModel.Id.Uuid as object;
+
+ // Create a json object to represent the point.
+ var outputJsonObject = new JsonObject
+ {
+ { keyPropertyName, JsonValue.Create(keyPropertyValue) },
+ };
+
+ // Add each vector property if embeddings are included in the point.
+ if (options?.IncludeVectors is true)
+ {
+ foreach (var vectorProperty in this._vectorPropertiesInfo)
+ {
+ var propertyName = VectorStoreRecordPropertyReader.GetSerializedPropertyName(vectorProperty);
+
+ if (this._options.HasNamedVectors)
+ {
+ if (storageModel.Vectors.Vectors_.Vectors.TryGetValue(propertyName, out var vector))
+ {
+ outputJsonObject.Add(propertyName, new JsonArray(vector.Data.Select(x => JsonValue.Create(x)).ToArray()));
+ }
+ }
+ else
+ {
+ outputJsonObject.Add(propertyName, new JsonArray(storageModel.Vectors.Vector.Data.Select(x => JsonValue.Create(x)).ToArray()));
+ }
+ }
+ }
+
+ // Add each payload property.
+ foreach (var payloadProperty in this._payloadPropertiesInfo)
+ {
+ var propertyName = VectorStoreRecordPropertyReader.GetSerializedPropertyName(payloadProperty);
+ if (storageModel.Payload.TryGetValue(propertyName, out var value))
+ {
+ outputJsonObject.Add(propertyName, ConvertFromGrpcFieldValueToJsonNode(value));
+ }
+ }
+
+ // Convert from json object to the target data model.
+ return JsonSerializer.Deserialize(outputJsonObject)!;
+ }
+
+ ///
+ /// Convert the given to the correct native type based on its properties.
+ ///
+ /// The value to convert to a native type.
+ /// The converted native value.
+ /// Thrown when an unsupported type is enountered.
+ private static JsonNode? ConvertFromGrpcFieldValueToJsonNode(Value payloadValue)
+ {
+ return payloadValue.KindCase switch
+ {
+ Value.KindOneofCase.NullValue => null,
+ Value.KindOneofCase.IntegerValue => JsonValue.Create(payloadValue.IntegerValue),
+ Value.KindOneofCase.StringValue => JsonValue.Create(payloadValue.StringValue),
+ Value.KindOneofCase.DoubleValue => JsonValue.Create(payloadValue.DoubleValue),
+ Value.KindOneofCase.BoolValue => JsonValue.Create(payloadValue.BoolValue),
+ Value.KindOneofCase.ListValue => new JsonArray(payloadValue.ListValue.Values.Select(x => ConvertFromGrpcFieldValueToJsonNode(x)).ToArray()),
+ Value.KindOneofCase.StructValue => new JsonObject(payloadValue.StructValue.Fields.ToDictionary(x => x.Key, x => ConvertFromGrpcFieldValueToJsonNode(x.Value))),
+ _ => throw new VectorStoreRecordMappingException($"Unsupported grpc value kind {payloadValue.KindCase}."),
+ };
+ }
+
+ ///
+ /// Convert the given to a object that can be stored in Qdrant.
+ ///
+ /// The object to convert.
+ /// The converted Qdrant value.
+ /// Thrown when an unsupported type is enountered.
+ private static Value ConvertToGrpcFieldValue(object? sourceValue)
+ {
+ var value = new Value();
+ if (sourceValue is null)
+ {
+ value.NullValue = NullValue.NullValue;
+ }
+ else if (sourceValue is int intValue)
+ {
+ value.IntegerValue = intValue;
+ }
+ else if (sourceValue is long longValue)
+ {
+ value.IntegerValue = longValue;
+ }
+ else if (sourceValue is string stringValue)
+ {
+ value.StringValue = stringValue;
+ }
+ else if (sourceValue is float floatValue)
+ {
+ value.DoubleValue = floatValue;
+ }
+ else if (sourceValue is double doubleValue)
+ {
+ value.DoubleValue = doubleValue;
+ }
+ else if (sourceValue is bool boolValue)
+ {
+ value.BoolValue = boolValue;
+ }
+ else if (sourceValue is IEnumerable ||
+ sourceValue is IEnumerable ||
+ sourceValue is IEnumerable ||
+ sourceValue is IEnumerable ||
+ sourceValue is IEnumerable ||
+ sourceValue is IEnumerable)
+ {
+ var listValue = sourceValue as IEnumerable