A busca por vetores, ou "vector search", é uma abordagem inovadora na recuperação de informações, empregando representações numéricas para otimizar a busca em grandes conjuntos de dados. Essa técnica transcende as limitações dos métodos tradicionais, baseando-se na similaridade vetorial para encontrar relações semânticas entre entidades. Ao converter elementos de dados em vetores, a busca por vetores possibilita a comparação eficiente, não apenas considerando características físicas, mas também capturando significados semânticos. Amplamente aplicada em processamento de linguagem natural e aprendizado de máquina, essa abordagem impulsiona avanços em recomendações personalizadas, análise exploratória de dados e otimização de motores de busca, representando uma evolução notável na recuperação de informações.

Para esse post, utilizaremos o pgvector que é uma extensão para o PostgreSQL que facilita a implementação de busca por vetores no banco de dados. Já para a criação dos vetores (embeddings), utilizaremos o Azure Open AI com o modelo embedding-ada-002!

Vamos inicialmente criar um diretório para servir como raiz para nosso projeto.

mkdir VectorSearch

Após isso, já podemos criar uma solution e um gitignore.

 dotnet new sln
 dotnet new gitignore

Para a solução inicial do nosso projeto, vamos precisar dos seguintes pacotes NuGet.

dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL
dotnet add package Microsoft.EntityFrameworkCore.Design 

Além disso, vamos criar o arquivo docker-compose.yaml. Ele será responsável por criar uma instância do banco de dados PostgreSQL que já possui o pgvector instalado, por meio da imagem ankane/pgvector.

services:
  db:
    hostname: db
    image: ankane/pgvector
    ports:
     - 5432:5432
    restart: always
    environment:
      - POSTGRES_DB=VectorStore
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=Teste12345!
      - POSTGRES_HOST_AUTH_METHOD=trust

Vamos subir as dependências através do comando docker compose up.

docker compose up -d

Nesse post, vou utilizar o Rider como IDE. Mas fique a vontade para usar sua IDE preferida!

Dentro da solution criada anteriormente, vamos criar uma minimal web api em .NET 8.

Precisaremos adicionar a configuração da string de conexão do banco de dados no arquivo appsettings.json.

 "ConnectionStrings": {
    "DefaultConnection": "Server=localhost;Database=VectorStore;Port=5444;User Id=postgres;Password=Teste12345!"
  }

Vamos começar o desenvolvimento da web api para busca de livros em nossa base local. Inicialmente devemos criar a entidade Book e seu respectivo mapeamento para tabelas no PostgreSQL (recomendo uma leitura profunda sobre Entity framework core code-first!).

namespace VectorSearch.Api.Models;

public class Book
{
    public Guid Id { get; set; }
    public string Name { get; set; } = default!;
    public string Description { get; set; } = default!;
}
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

namespace VectorSearch.Api.Models.Configuration_;

public class BookConfiguration : IEntityTypeConfiguration<Book>
{
    public void Configure(EntityTypeBuilder<Book> builder)
    {
        builder.HasKey(e => e.Id);

        builder.Property(e => e.Name);

        builder.Property(e => e.Description);

         builder.HasData( new List<Book>()
        {
            new Book
            {
                Id = Guid.Parse("e3e8e383-e69e-4c2d-94e6-d7e2a59d714d"),
                Name = "The Hobbit",
                Description = """
                              The Hobbit, written by J.R.R. Tolkien, follows Bilbo Baggins, a reluctant hobbit hero, as he joins a band of dwarves led by Thorin Oakenshield on a perilous quest to reclaim the Lonely Mountain and its treasure from the fearsome dragon Smaug, encountering trolls, goblins, elves, and the enigmatic Gollum, while discovering courage and cunning within himself, ultimately shaping the events that will unfold in the epic world of Middle-earth
                              """
            },
            new Book
            {
                Id = Guid.Parse("35cb9b12-85a8-46f4-86be-12e05778bef3"),
                Name = "The Lord of the Rings",
                Description = """
                              In J.R.R. Tolkien's 'The Lord of the Rings,' a young hobbit named Frodo Baggins embarks on a perilous journey with a diverse fellowship to destroy the One Ring and thwart the dark lord Sauron, facing battles, betrayals, and the complexities of Middle-earth, as alliances are forged, friendships tested, and destinies unfold in a sweeping epic that explores themes of power, sacrifice, and the enduring triumph of hope in the face of darkness.
                              """
            },
            new Book
            {
                Id = Guid.Parse("c4f49049-7731-4400-bcbd-afa977185c2b"),
                Name = "The Shining",
                Description = """
                              In Stephen King's 'The Shining,' the Torrance family—Jack, Wendy, and their psychic son Danny—takes on the winter caretaking of the haunted Overlook Hotel, where Jack's descent into madness, fueled by supernatural forces, threatens their lives and sanity; as Danny's psychic abilities intensify, the hotel's malevolent spirits come to life, and the family confronts a sinister past, culminating in a chilling battle between good and evil, exploring themes of isolation, addiction, and the eerie intersection of the supernatural with the vulnerabilities of the human psyche
                              """
            },
            new Book
            {
                Id = Guid.Parse("ed01fe5d-aff9-4f49-8b39-acc86e7bcef5"),
                Name = "The Iliad",
                Description = """
                              Homer's 'The Iliad' recounts the Trojan War's epic battles, centered around the wrath of Achilles, a Greek hero, delving into themes of honor, fate, and the human cost of war, as gods intervene and mortals grapple with mortality in a timeless narrative of heroism and tragedy
                              """
            },
            new Book
            {
                Id = Guid.Parse("6b406924-35df-452c-b306-1d91fc98fe81"),
                Name = "Mastering the Art of French Cooking",
                Description = """
                              Julia Child's 'Mastering the Art of French Cooking' is a culinary masterpiece, guiding aspiring chefs with meticulous detail through the intricacies of French cuisine, presenting a comprehensive blend of recipes, techniques, and anecdotes that demystify the culinary world and ignite a passion for the art of cooking, forever changing the landscape of American gastronomy
                              """
            }
            
        });
    }
}

Sobre o item acima:

  • Fazemos o seeding diretamente na classe BookConfiguration;
  • Gerei uma pequena descrição de cada livro no ChatGPT.

Temos o AppDbContext que herda de DbContext.

using Microsoft.EntityFrameworkCore;
using VectorSearch.Api.Models;

namespace VectorSearch.Api.Data;

public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
    public DbSet<Book> Books { get; set; } = default!;
    
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

    
  modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
    }
}

Seguindo, é hora de criar a migration e aplicá-la ao banco de dados.

Temos o resultado:

Os dados já estão persistidos no banco dados, portanto, já podemos criar o serviço que irá fazer a busca com base nos parâmetros enviados.

using VectorSearch.Api.Models;

namespace VectorSearch.Api.Services;

public interface ISearchService
{
    public Task<List<Book>> SearcAsync(string? name, string? description);
}
using Microsoft.EntityFrameworkCore;
using VectorSearch.Api.Data;
using VectorSearch.Api.Models;

namespace VectorSearch.Api.Services;

public class SearchService(AppDbContext appDbContext, IEmbeddingService embeddingService, IConfiguration configuration) : ISearchService
{
    public async Task<List<Book>> SearcAsync(string? name, string? description)
    {
        var query = appDbContext.Books.AsQueryable();

        if (!string.IsNullOrWhiteSpace(name))
        {
            query = query.Where(x => x.Name.ToLower().Contains(name.Trim().ToLower()));
        }
        
        if (!string.IsNullOrWhiteSpace(description))
        {
            query = query.Where(x => x.Description.ToLower().Contains(description.Trim().ToLower()));
        }
        
        return await query.ToListAsync();
    }
}

Sobre a implementação acima:

  • É uma filtragem simples que utiliza o método Contains();
  • É feito uma filtragem com base no nome do livro e outra com base na sua descrição;
  • Utilizamos o IQueryable para implementação da filtragem.

Para executarmos a aplicação, falta só configuramos a injeção de dependência do serviço de busca, o Entity Framework Core e o endpoint "/search-books":

using Microsoft.EntityFrameworkCore;
using VectorSearch.Api.Data;
using VectorSearch.Api.Services;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddScoped<ISearchService, SearchService>();

var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseNpgsql(connectionString)
);

var app = builder.Build();

app.UseSwagger();
app.UseSwaggerUI();

app.UseHttpsRedirection();

app.MapGet("/search-books", async (string? name, string? description ,ISearchService searchService) =>
    {
        return Results.Ok(await searchService.SearcAsync(name, description));
    })
.WithName("GetSearchBooks")
.WithOpenApi();

app.Run();

A versão inicial da web api está pronta, vamos executá-la.

Com a web api rodando, podemos chamar o endpoint de busca, como feito no seguinte exemplo:

Já podemos refatorar nossa aplicação para fazer a busca com base na semântica da descrição dos livros!

Primeiramente vamos instalar os seguintes pacotes NuGet:

dotnet add package Azure.AI.OpenAI --version 1.0.0-beta.12
dotnet add package Pgvector.EntityFrameworkCore

Vamos precisar que o serviço do Azure Open AI seja criado. No momento de escrita deste post, precisar aplicar uma requisição para ter acesso a esse recurso de nuvem.

Com o recurso de nuvem criado, iremos criar um deployment do modelo text-embedding-ada-002, especializado em criar Embeddings.

Já na nossa aplicação, precisaremos criar um novo campo na tabela de Books para armazenar os Embeddings que serão criados. Esse novo campo será do tipo Vector!

    [JsonIgnore]
    public Vector? Embedding { get; set; }

Já nas configurações do mapeamento, vamos definir esse novo campo e criar um index (veja aqui!). Os vetores criados pelo Embeddings do Azure Open AI possuem um tamanho de 1536, mesmo valor que foi definido neste novo campo.

    builder.Property(x => x.Embedding)
            .HasColumnType("vector(1536)");
        
        builder.HasIndex(x => x.Embedding)
            .HasMethod("hnsw")
            .HasOperators("vector_cosine_ops");

Além disso, precisamos dizer para o banco de dados, que iremos utilizar a extensão "vector". Isso é feito no método OnModelCreating do Entity Framework Core, como mostrado abaixo:

protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.HasPostgresExtension("vector");
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
        base.OnModelCreating(modelBuilder);
    }

Vamos gerar uma nova migration. Temos o resultado após a sua implantação:

Para transformar textos em Embeddings, vamos criar um serviço com base no Azure Open AI.

namespace VectorSearch.Api.Services;

public interface IEmbeddingService
{
    public float[] CreateEmbedding(string text);
}
using Azure;
using Azure.AI.OpenAI;

namespace VectorSearch.Api.Services;

public class AzureOpenAiService(IConfiguration configuration) : IEmbeddingService
{
    public float[] CreateEmbedding(string text)
    {
        Uri oaiEndpoint = new (configuration.GetSection("AzureOpenAI:Url").Value!);
        string oaiKey = configuration.GetSection("AzureOpenAI:Key").Value!;

        var credentials = new AzureKeyCredential(oaiKey);

        var client = new OpenAIClient(oaiEndpoint, credentials);

        EmbeddingsOptions embeddingOptions = new()
        {
            DeploymentName = "text-embedding-ada-002",
            Input = { text }
        };

        var returnValue = client.GetEmbeddings(embeddingOptions);
        return returnValue.Value.Data[0].Embedding.ToArray();
    }
}

Precisamos adicionar novas configurações, relativas ao Azure Open AI, ao appsettings.json.

  "AzureOpenAI": {
    "Url": "https://<your-service-name>.openai.azure.com/",
    "Key": "<your-key>"
  },

Para configurar o DI desse serviço recém criado, vamos adicionar o seguinte trecho de código ao Program.cs.

builder.Services.AddScoped<IEmbeddingService, AzureOpenAiService>();

Para transformar todas as descrições das entidades de Books em Embeddings, iremos criar um novo endpoint:


app.MapPost("/create-embeddings", async (IEmbeddingService embeddingService, AppDbContext appDbContext) =>
    {
        var books = await appDbContext.Books.ToListAsync();
        foreach (var book in books)
        {
            if (book.Embedding == null)
            {
                var embedding = embeddingService.CreateEmbedding(book.Description);
                book.Embedding = new Vector(embedding);
            }
            
            await appDbContext.SaveChangesAsync();
        }
        return Results.Ok();
    })
    .WithName("CreateEmbeddings")
    .WithOpenApi();

Vamos executar a aplicação e chamar esse novo endpoint.

Observe que para cada registro no banco dados, foi gerado um vetor de dimensão 1536, e foi salvo no campo Embeddings da tabela Books.

Com os dados prontos, já podemos refatorar o serviço de busca para filtrar os itens com base na semântica da descrição.

public interface ISearchService
{
    public Task<List<Book>> SearcAsync(string? text);
}

public class SearchService(AppDbContext appDbContext, IEmbeddingService embeddingService, IConfiguration configuration) : ISearchService
{
    public async Task<List<Book>> SearcAsync(string? text)
    {
        var query = appDbContext.Books.AsQueryable();

        if (!string.IsNullOrWhiteSpace(text))
        {
            var  = configuration.GetValue<double>("Application:SearchThreshold");

            // Embeddings
            var embedding = embeddingService.CreateEmbedding(text);
            var embeddingVector = new Vector(embedding);

            query = query
                .Where(x => x.Embedding!.CosineDistance(embeddingVector) <= searchThreshold);
        }
        
        return await query.ToListAsync();
    }
}

Sobre a implementação acima:

  • Ainda estamos utilizamos o IQueryable como base da pesquisa;
  • Geramos o embeddings do texto enviado como parâmetro;
  • Com o embeddings do valor enviado, fazemos uma busca por similaridade entre vetores, utilizando o método de similaridade por cosseno CosineDistance do pgvector;
  • Retornamos apenas os itens do banco em que a similaridade por cosseno é menor ou igual ao valor searchThreshold;
  • O searchThreshold é definido no appsettings.json, e seu valor foi escolhido empiricamente com base nos dados desse post. Ele serve como uma sensibilidade para o método de busca de similaridade por cosseno.
  "Application":   {
    "SearchThreshold": 0.20
  }

Com essa refatoração do método de busca feito, temos uma visão geral do projeto:

Já podemos executar a aplicação e fazer algumas buscas por semântica.

E finalizamos o post aqui!

Você já pode baixar o projeto por esse link, e não esquece de me seguir no LinkedIn!

GitHub - TallesValiatti/VectorSearch
Contribute to TallesValiatti/VectorSearch development by creating an account on GitHub.

Até a próxima, abraços!

💡
Podemos te ajudar com uma revisão 100% gratuita do seu ambiente cloud.
Share this post