Partilhar via


Como criar um mapeador personalizado para um conector de armazenamento vetorial (pré-visualização)

Aviso

A funcionalidade de armazenamento vetor do Semantic Kernel está em fase de testes, e melhorias que exigem alterações disruptivas ainda podem ocorrer em circunstâncias limitadas antes do lançamento.

Aviso

O suporte para mapeadores personalizados pode ser preterido no futuro, uma vez que a filtragem e a seleção de propriedades de destino não podem direcionar os tipos mapeados de nível superior.

Neste tutorial, mostraremos como pode substituir o mapeador padrão de uma coleção de registos de repositório vetorial pelo seu próprio mapeador.

Usaremos o Qdrant para demonstrar essa funcionalidade, mas os conceitos serão semelhantes para outros conectores.

Contexto

Cada conector do Vetor Store inclui um mapeador padrão que pode mapear do modelo de dados fornecido para o esquema de armazenamento suportado pelo armazenamento subjacente. Algumas lojas permitem muita liberdade em relação à forma como os dados são armazenados, enquanto outras lojas exigem uma abordagem mais estruturada, por exemplo, onde todos os vetores têm de ser adicionados a um dicionário de vetores e todos os campos não vetoriais a um dicionário de campos de dados. Portanto, o mapeamento é uma parte importante de abstrair as diferenças de cada implementação de armazenamento de dados.

Em alguns casos, o desenvolvedor pode querer substituir o mapeador padrão se, por exemplo,

  1. Eles querem usar um modelo de dados diferente do esquema de armazenamento.
  2. eles querem criar um mapeador de desempenho otimizado para seu cenário.
  3. O mapeador padrão não suporta uma estrutura de armazenamento que o desenvolvedor exige.

Todas as implementações do conector do Vetor Store permitem que você forneça um mapeador personalizado.

Diferenças por tipo de armazenamento vetorial

Os armazenamentos de dados subjacentes de cada conector do Vetor Store têm maneiras diferentes de armazenar dados. Portanto, o que se está a mapear na parte do armazenamento pode ser diferente para cada conector.

Por exemplo, se estiver usando o conector Qdrant, o tipo de armazenamento é uma PointStruct classe fornecida pelo SDK do Qdrant. Se estiver usando o conector JSON Redis, o tipo de armazenamento será uma string chave e um JsonNode, enquanto se estiver usando um conector JSON HashSet, o tipo de armazenamento será uma string chave e uma HashEntry matriz.

Se você quiser fazer mapeamento personalizado e quiser usar vários tipos de conector, precisará implementar um mapeador para cada tipo de conector.

Criando o modelo de dados

Nosso primeiro passo é criar um modelo de dados. Nesse caso, não anotaremos o modelo de dados com atributos, pois forneceremos uma definição de registro separada que descreve como será o esquema do banco de dados.

Observe também que este modelo é complexo, com classes separadas para vetores e informações adicionais do produto.

public class Product
{
    public ulong Id { get; set; }
    public string Name { get; set; }
    public string Description { get; set; }
    public ProductVectors Vectors { get; set; }
    public ProductInfo ProductInfo { get; set; }
}

public class ProductInfo
{
    public double Price { get; set; }
    public string SupplierId { get; set; }
}

public class ProductVectors
{
    public ReadOnlyMemory<float> NameEmbedding { get; set; }
    public ReadOnlyMemory<float> DescriptionEmbedding { get; set; }
}

Criando a definição de registro

Precisamos criar uma instância de definição de registro para definir como será o esquema de banco de dados. Normalmente, um conector exigirá essas informações para fazer o mapeamento ao usar o mapeador padrão. Como estamos criando um mapeador personalizado, isso não é necessário para o mapeamento, no entanto, o conector ainda exigirá essas informações para criar coleções no armazenamento de dados.

Observe que a definição aqui é diferente do modelo de dados acima. Para armazenar ProductInfo, temos uma propriedade string chamada ProductInfoJson, e os dois vetores são definidos no mesmo nível que os campos Id, Name e Description.

using Microsoft.Extensions.VectorData;

var productDefinition = new VectorStoreRecordDefinition
{
    Properties = new List<VectorStoreRecordProperty>
    {
        new VectorStoreRecordKeyProperty("Id", typeof(ulong)),
        new VectorStoreRecordDataProperty("Name", typeof(string)) { IsFilterable = true },
        new VectorStoreRecordDataProperty("Description", typeof(string)),
        new VectorStoreRecordDataProperty("ProductInfoJson", typeof(string)),
        new VectorStoreRecordVectorProperty("NameEmbedding", typeof(ReadOnlyMemory<float>)) { Dimensions = 1536 },
        new VectorStoreRecordVectorProperty("DescriptionEmbedding", typeof(ReadOnlyMemory<float>)) { Dimensions = 1536 }
    }
};

Importante

Para esse cenário, não seria possível usar atributos em vez de uma definição de registro, uma vez que o esquema de armazenamento não se assemelha ao modelo de dados.

Criando o mapeador personalizado

Todos os mapeadores implementam a interface Microsoft.SemanticKernel.Data.IVectorStoreRecordMapper<TRecordDataModel, TStorageModel>genérica. TRecordDataModel será diferente dependendo do modelo de dados que o desenvolvedor deseja usar e TStorageModel será determinado pelo tipo de Vetor Store.

Para Qdrant TStorageModel é Qdrant.Client.Grpc.PointStruct.

Portanto, temos que implementar um mapeador que irá mapear entre o nosso Product modelo de dados e um Qdrant PointStruct.

using Microsoft.Extensions.VectorData;
using Qdrant.Client.Grpc;

public class ProductMapper : IVectorStoreRecordMapper<Product, PointStruct>
{
    public PointStruct MapFromDataToStorageModel(Product dataModel)
    {
        // Create a Qdrant PointStruct to map our data to.
        var pointStruct = new PointStruct
        {
            Id = new PointId { Num = dataModel.Id },
            Vectors = new Vectors(),
            Payload = { },
        };

        // Add the data fields to the payload dictionary and serialize the product info into a json string.
        pointStruct.Payload.Add("Name", dataModel.Name);
        pointStruct.Payload.Add("Description", dataModel.Description);
        pointStruct.Payload.Add("ProductInfoJson", JsonSerializer.Serialize(dataModel.ProductInfo));

        // Add the vector fields to the vector dictionary.
        var namedVectors = new NamedVectors();
        namedVectors.Vectors.Add("NameEmbedding", dataModel.Vectors.NameEmbedding.ToArray());
        namedVectors.Vectors.Add("DescriptionEmbedding", dataModel.Vectors.DescriptionEmbedding.ToArray());
        pointStruct.Vectors.Vectors_ = namedVectors;

        return pointStruct;
    }

    public Product MapFromStorageToDataModel(PointStruct storageModel, StorageToDataModelMapperOptions options)
    {
        var product = new Product
        {
            Id = storageModel.Id.Num,

            // Retrieve the data fields from the payload dictionary and deserialize the product info from the json string that it was stored as.
            Name = storageModel.Payload["Name"].StringValue,
            Description = storageModel.Payload["Description"].StringValue,
            ProductInfo = JsonSerializer.Deserialize<ProductInfo>(storageModel.Payload["ProductInfoJson"].StringValue)!,

            // Retrieve the vector fields from the vector dictionary.
            Vectors = new ProductVectors
            {
                NameEmbedding = new ReadOnlyMemory<float>(storageModel.Vectors.Vectors_.Vectors["NameEmbedding"].Data.ToArray()),
                DescriptionEmbedding = new ReadOnlyMemory<float>(storageModel.Vectors.Vectors_.Vectors["DescriptionEmbedding"].Data.ToArray())
            }
        };

        return product;
    }
}

Usando seu mapeador personalizado com uma coleção de registros

Para usar o mapeador personalizado que criamos, precisamos passá-lo para a coleção de registros no momento da construção. Também precisamos passar a definição de registro que criamos anteriormente, para que as coleções sejam criadas no armazenamento de dados usando o esquema correto. Mais uma configuração que é importante aqui, é o modo de vetores nomeados do Qdrant, já que temos mais de um vetor. Sem este modo ativado, apenas um vetor é suportado.

using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Connectors.Qdrant;
using Qdrant.Client;

var productMapper = new ProductMapper();
var collection = new QdrantVectorStoreRecordCollection<Product>(
    new QdrantClient("localhost"),
    "skproducts",
    new()
    {
        HasNamedVectors = true,
        PointStructCustomMapper = productMapper,
        VectorStoreRecordDefinition = productDefinition
    });

Usando um mapeador personalizado com IVectorStore

Ao usar IVectorStore para obter IVectorStoreRecordCollection instâncias de objeto, não é possível fornecer um mapeador personalizado diretamente para o GetCollection método. Isso ocorre porque os mapeadores personalizados diferem para cada tipo de repositório vetorial e impossibilitariam o uso IVectorStore para se comunicar com qualquer implementação de repositório vetorial.

No entanto, é possível substituir a implementação padrão do GetCollection e fornecer sua própria implementação personalizada do repositório de vetores.

Aqui está um exemplo em que herdamos do QdrantVectorStore e substituímos o método GetCollection para fazer a construção personalizada.

private sealed class QdrantCustomVectorStore(QdrantClient qdrantClient, VectorStoreRecordDefinition productDefinition)
    : QdrantVectorStore(qdrantClient)
{
    public override IVectorStoreRecordCollection<TKey, TRecord> GetCollection<TKey, TRecord>(string name, VectorStoreRecordDefinition? vectorStoreRecordDefinition = null)
    {
        // If the record definition is the product definition and the record type is the product data
        // model, inject the custom mapper into the collection options.
        if (vectorStoreRecordDefinition == productDefinition && typeof(TRecord) == typeof(Product))
        {
            var customCollection = new QdrantVectorStoreRecordCollection<Product>(
                qdrantClient,
                name,
                new()
                {
                    HasNamedVectors = true,
                    PointStructCustomMapper = new ProductMapper(),
                    VectorStoreRecordDefinition = vectorStoreRecordDefinition
                }) as IVectorStoreRecordCollection<TKey, TRecord>;
            return customCollection!;
        }

        // Otherwise, just create a standard collection.
        return base.GetCollection<TKey, TRecord>(name, vectorStoreRecordDefinition);
    }
}

Para usar o armazenamento de vetores de substituição, registe-o com o contêiner de injeção de dependência ou simplesmente use-o diretamente como faria com um QdrantVectorStorenormal.

// When registering with the dependency injection container on the kernel builder.
kernelBuilder.Services.AddTransient<IVectorStore>(
    (sp) =>
    {
        return new QdrantCustomVectorStore(
            new QdrantClient("localhost"),
            productDefinition);
    });
// When constructing the Vector Store instance directly.
var vectorStore = new QdrantCustomVectorStore(
    new QdrantClient("localhost"),
    productDefinition);

Agora você pode usar o repositório de vetores normalmente para obter uma coleção.

var collection = vectorStore.GetCollection<ulong, Product>("skproducts", productDefinition);

Brevemente

Mais informações em breve.

Brevemente

Mais informações em breve.