Busca semântica com pgvector, EF Core e Azure OpenAI

Busca semântica com pgvector, EF Core e Azure OpenAI

4 de setembro de 2026

Busca por palavra-chave funciona bem quando o usuário digita exatamente o que está no catálogo. O problema começa quando ele pesquisa "algo pra correr na chuva" e o produto no banco se chama "Tênis de corrida impermeável". Nenhuma LIKE ou full-text resolve isso com elegância. A saída é transformar texto em vetores (embeddings) e comparar significado, não strings.

Neste artigo vamos montar uma Minimal API em .NET que cadastra produtos, gera embeddings com Azure OpenAI e faz vector search no PostgreSQL usando a extensão pgvector via Entity Framework Core. O código é o mesmo fluxo do projeto demo SemanticSearchApi.

Link do GitHub: semantic-search-pgvector-demo

O que você vai ver

  1. Criar o resource e o deployment de embedding no Azure
  2. Preparar o PostgreSQL com pgvector via Docker Compose
  3. Mapear a coluna vector(1536) no EF Core e o índice HNSW
  4. Gerar embedding no cadastro do produto
  5. Buscar por similaridade com CosineDistance

Pré-requisitos:

  • .NET 10 SDK
  • Docker instalado e rodando
  • Assinatura Azure com permissão para criar resource de Azure OpenAI
  • Noções de EF Core e Minimal APIs

Stack do exemplo:

Peça Versão / valor
Target framework net10.0
Azure.AI.OpenAI 2.1.0
Npgsql.EntityFrameworkCore.PostgreSQL 10.0.3
Pgvector.EntityFrameworkCore 0.3.0
Modelo de embedding text-embedding-3-small (1536 dimensões)

Um embedding é um array de números de ponto flutuante que representa o significado de um texto em um espaço de alta dimensão. Textos parecidos ficam “perto” uns dos outros nesse espaço.

Fluxo da aplicação:

  1. No cadastro, concatena nome + descrição do produto, manda pro modelo de embedding e grava o vetor na coluna Embedding
  2. Na busca, gera o embedding da query do usuário e pede ao banco os produtos com menor distância cosseno em relação a esse vetor

Quem faz a matemática de similaridade é o pgvector no PostgreSQL. O EF Core só traduz CosineDistance para o operador <=> do pgvector.

1. Setup do Azure OpenAI

Precisamos de um resource Azure OpenAI e de um deployment do modelo de embedding.

Criar o resource (portal)

  1. Acesse o Azure portal
  2. Create a resource → busque Azure OpenAICreate
    az-semantic-search-1.png
  3. Preencha subscription, resource group, região, nome e pricing tier
  4. Prossiga com a configuração padrão dos outros passos clicando sempre em Next até chegar em Review + create
  5. Revise e crie o resource
    az-semantic-search-2.png

Deploy do modelo de embedding

  1. Abra o resource no Microsoft Foundry (portal classic, se for o fluxo que você estiver usando)
  2. Vá em DeploymentsDeploy modelDeploy base model
    az-semantic-search-3.png
  3. Escolha text-embedding-3-small
  4. Defina o deployment name — no exemplo usamos o mesmo nome do modelo: text-embedding-3-small
    az-semantic-search-4.png
  5. Confirme o deploy e aguarde o status de sucesso.

Anote:

  • Endpoint base no formato https://SEU-RECURSO.openai.azure.com/
  • API key do resource
  • Nome do deployment exatamente como criou

2. Criar e executar o container do PostgreSQL com a extensão pgvector via Docker Compose

Crie o arquivo docker-compose.yml na raiz do projeto com o conteúdo abaixo (senha e dbname podem ser alterados, desde que batam com a connection string):

version: '3.8'

services:
  db:
    image: pgvector/pgvector:pg16
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: Devenv12345!
      POSTGRES_DB: ecommercedb
    ports:
      - "5432:5432"

Execute docker compose up -d para subir o container.

3. Projeto e pacotes

dotnet new web -n SemanticSearchApi
cd SemanticSearchApi

dotnet add package Azure.AI.OpenAI --version 2.1.0
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL --version 10.0.3
dotnet add package Pgvector.EntityFrameworkCore --version 0.3.0
dotnet add package Microsoft.EntityFrameworkCore.Design --version 10.0.10

O pacote Pgvector.EntityFrameworkCore puxa o tipo Vector e os métodos de distância traduzíveis para SQL (CosineDistance, L2Distance, etc.).

4. Entidade e DbContext

Crie AppDbContext.cs. O ponto crítico: a dimensão da coluna precisa bater com a dimensão do modelo. O text-embedding-3-small gera vetores com 1536 dimensões por padrão.

No mesmo OnModelCreating, habilitamos a extensão vector e o índice HNSW com vector_cosine_ops — o operator class certo para CosineDistance. Sem isso, o demo ordena por distância em full scan; com o índice, o pgvector usa nearest neighbor aproximado.

using System.ComponentModel.DataAnnotations.Schema;
using Pgvector;
using Microsoft.EntityFrameworkCore;

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string Description { get; set; } = string.Empty;

    // Define a propriedade Embedding como um vetor de 1536 dimensões, que é o tamanho do embedding gerado pelo modelo "text-embedding-3-small" da OpenAI.
    [Column(TypeName = "vector(1536)")]
    public Vector? Embedding { get; set; }
}

public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }

    public DbSet<Product> Products { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Habilita a extensão do pgvector no banco de dados PostgreSQL
        modelBuilder.HasPostgresExtension("vector");

        modelBuilder.Entity<Product>()
            .HasIndex(p => p.Embedding)
            .HasMethod("hnsw")
            .HasOperators("vector_cosine_ops");
    }
}

Sem o TypeName = "vector(1536)", o Npgsql não sabe o tipo exato da coluna. Se você trocar de modelo ou de dimensões, altere o atributo e a coluna no banco.

5. Configurar EF Core e Azure OpenAI no Program.cs

Dois detalhes que não podem faltar:

  1. UseVector() no UseNpgsql — registra mapeamento do tipo e os translators de distância
  2. Endpoint base + nome do deployment no GetEmbeddingClient

Connection string, endpoint, key e nome do deployment vêm do appsettings.json (não hardcode no Program.cs).

appsettings.json:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "ConnectionStrings": {
    "Default": "Host=localhost;Database=ecommercedb;Username=postgres;Password=Devenv12345!"
  },
  "AzureOpenAI": {
    "Endpoint": "https://SEU-RECURSO.openai.azure.com/",
    "Key": "SUA_API_KEY",
    "EmbeddingDeployment": "text-embedding-3-small"
  }
}

Program.cs:

using Azure.AI.OpenAI;
using Microsoft.EntityFrameworkCore;
using Pgvector.EntityFrameworkCore;
using Pgvector;
using System.ClientModel;

var builder = WebApplication.CreateBuilder(args);

var connectionString = builder.Configuration.GetConnectionString("Default")
    ?? throw new InvalidOperationException("Connection string 'Default' não configurada.");

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseNpgsql(connectionString, o => o.UseVector()));

var openAiEndpoint = builder.Configuration["AzureOpenAI:Endpoint"]
    ?? throw new InvalidOperationException("AzureOpenAI:Endpoint não configurado.");
var openAiKey = builder.Configuration["AzureOpenAI:Key"]
    ?? throw new InvalidOperationException("AzureOpenAI:Key não configurado.");
var embeddingDeploymentName = builder.Configuration["AzureOpenAI:EmbeddingDeployment"]
    ?? throw new InvalidOperationException("AzureOpenAI:EmbeddingDeployment não configurado.");

var aiClient = new AzureOpenAIClient(
    new Uri(openAiEndpoint),
    new ApiKeyCredential(openAiKey));

builder.Services.AddSingleton(aiClient);

var app = builder.Build();

using (var scope = app.Services.CreateScope())
{
    var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    await db.Database.MigrateAsync();
}

// endpoints abaixo...

app.Run();

Crie a migration inicial:

dotnet ef migrations add Initial
dotnet run

A migration gera a extensão vector no database, a coluna Embedding como vector(1536) e o índice HNSW IX_Products_Embedding com vector_cosine_ops.

6. Cadastro: gerar e persistir o embedding

No POST, montamos um texto estável a partir do produto, pedimos o embedding ao Azure e salvamos o Vector junto com nome e descrição.

app.MapPost("/api/products", async (CreateProductRequest request, AppDbContext db, AzureOpenAIClient aiClient) =>
{
    if (string.IsNullOrWhiteSpace(request.Name))
        return Results.BadRequest("O nome do produto não pode estar vazio.");

    if (string.IsNullOrWhiteSpace(request.Description))
        return Results.BadRequest("A descrição do produto não pode estar vazia.");

    var embeddingClient = aiClient.GetEmbeddingClient(embeddingDeploymentName);
    var textToEmbed = $"{request.Name}. {request.Description}";
    var embedResponse = await embeddingClient.GenerateEmbeddingAsync(textToEmbed);
    var embedding = new Vector(embedResponse.Value.ToFloats().ToArray());

    var product = new Product
    {
        Name = request.Name.Trim(),
        Description = request.Description.Trim(),
        Embedding = embedding
    };

    db.Products.Add(product);
    await db.SaveChangesAsync();

    return Results.Created($"/api/products/{product.Id}", new
    {
        product.Id,
        product.Name,
        product.Description
    });
});

record CreateProductRequest(string Name, string Description);

Por que Nome. Descrição? Porque o embedding “enxerga” o texto que você manda. Se na busca o usuário descreve uso ou benefício, vale a pena o cadastro carregar esse contexto na descrição — não só o nome comercial seco.

O SDK devolve ReadOnlyMemory<float>; o construtor de Pgvector.Vector aceita o float[] correspondente.

7. Busca: CosineDistance no LINQ

Aqui está o coração da busca semântica:

app.MapGet("/api/products/search", async (string query, AppDbContext db, AzureOpenAIClient aiClient) =>
{
    if (string.IsNullOrWhiteSpace(query))
        return Results.BadRequest("A consulta de pesquisa não pode estar vazia.");

    var embeddingClient = aiClient.GetEmbeddingClient(embeddingDeploymentName);

    var embedResponse = await embeddingClient.GenerateEmbeddingAsync(query);

    var queryVector = new Vector(embedResponse.Value.ToFloats().ToArray());

    const double maxDistance = 0.6;

    var products = await db.Products
        .Where(p => p.Embedding!.CosineDistance(queryVector) < maxDistance)
        .OrderBy(p => p.Embedding!.CosineDistance(queryVector))
        .Select(p => new { p.Name, p.Description })
        .AsNoTracking()
        .ToListAsync();

    return Results.Ok(products);
});

O que o EF/pgvector faz:

C# SQL (pgvector)
CosineDistance(queryVector) operador <=>
OrderBy na distância ORDER BY embedding <=> @query
filtro < maxDistance descarta resultados semanticamente fracos
índice HNSW + vector_cosine_ops acelera o nearest neighbor no operador <=>

No pgvector, a cosine distance vai de 0 (mesma direção) a 2 (direções opostas). Quanto menor, mais parecido. O limiar 0.6 é uma escolha de produto: mais baixo = resultados mais restritos; mais alto = mais recall e mais ruído. Vale calibrar com dados reais do catálogo.

8. Testando o fluxo

Suba a API:

dotnet run

Cadastre alguns produtos:

curl -X POST http://localhost:5015/api/products -H "Content-Type: application/json" -d '{"name":"Notebook Dell XPS 15","description":"Notebook premium com processador Intel Core i7, 32GB de RAM, SSD 1TB e tela OLED 15.6 polegadas."}'

curl -X POST http://localhost:5015/api/products -H "Content-Type: application/json" -d '{"name":"Fone de Ouvido Sony WH-1000XM5","description":"Headset com cancelamento de ruído ativo, Bluetooth 5.2 e até 30 horas de bateria."}'

curl -X POST http://localhost:5015/api/products -H "Content-Type: application/json" -d '{"name":"Headset HyperX Cloud II","description":"Headset gamer com som surround 7.1, microfone removível e conforto para longas sessões de jogo."}'

curl -X POST http://localhost:5015/api/products -H "Content-Type: application/json" -d '{"name":"Cama Ortopédica para Cachorro","description":"Cama macia e lavável para cães de médio e grande porte, com espuma de memória e capa impermeável."}'

Busque por intenção, não por keyword exata:

curl -G --data-urlencode "query=computador portátil" http://localhost:5015/api/products/search

curl -G --data-urlencode "query=fone com cancelamento de ruído" http://localhost:5015/api/products/search

curl -G --data-urlencode "query=headset para jogos" http://localhost:5015/api/products/search

curl -G --data-urlencode "query=cama para cachorro" http://localhost:5015/api/products/search

A expectativa:

  • computador portátil para trabalho → Notebook Dell XPS 15
  • fone com cancelamento de ruído → Sony WH-1000XM5
  • headset para jogos → HyperX Cloud II
  • cama para cachorro → Cama Ortopédica para Cachorro

Mesmo sem a palavra “notebook” na primeira query, o embedding deve puxar o XPS — e a cama de cachorro não deveria aparecer no meio dos fones.

Conclusão

Vector search deixa de ser “só pra time de ML” quando você encaixa três peças que já são familiares no dia a dia .NET:

  1. Azure OpenAI gera o embedding
  2. PostgreSQL + pgvector armazena e compara vetores (com HNSW para não varrer a tabela inteira)
  3. EF Core + Pgvector.EntityFrameworkCore expõe isso em LINQ com CosineDistance

Com uma Minimal API, um DbContext e dois endpoints, você já tem um catálogo que entende "computador portátil para trabalho". O próximo passo natural é calibrar o limiar de distância e enriquecer o texto embutido no cadastro.

Dicionário

  • Embedding — representação numérica (vetor) do significado de um texto; textos parecidos produzem vetores próximos.
  • Vector search (busca vetorial) — recuperar itens pela proximidade dos embeddings, em vez de igualdade de palavras.
  • Cosine distance — medida de distância baseada no ângulo entre dois vetores; no pgvector, 0 é o mais similar e 2 o mais oposto.
  • pgvector — extensão do PostgreSQL que adiciona o tipo vector e operadores/índices de similaridade.
  • Deployment (Azure OpenAI) — instância publicada de um modelo no seu resource; o nome do deployment é o identificador usado nas chamadas da API.
  • HNSW — algoritmo de índice aproximado (grafo) usado pelo pgvector para acelerar nearest neighbor search em escala.
  • Minimal API — estilo de API no ASP.NET Core em que rotas e handlers são mapeados de forma enxuta, sem controllers obrigatórios.
  • AsNoTracking — modo do EF Core em que as entidades lidas não entram no change tracker, reduzindo custo em consultas somente leitura.

Referências

Confira mais:

Fique por dentro das novidades

Assine nossa newsletter e receba as últimas atualizações e artigos diretamente em seu email.

Assinar gratuitamente