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
- Criar o resource e o deployment de embedding no Azure
- Preparar o PostgreSQL com pgvector via Docker Compose
- Mapear a coluna
vector(1536)no EF Core e o índice HNSW - Gerar embedding no cadastro do produto
- 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) |
Por que vector search
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:
- No cadastro, concatena nome + descrição do produto, manda pro modelo de embedding e grava o vetor na coluna
Embedding - 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)
- Acesse o Azure portal
- Create a resource → busque Azure OpenAI → Create

- Preencha subscription, resource group, região, nome e pricing tier
- Prossiga com a configuração padrão dos outros passos clicando sempre em Next até chegar em Review + create
- Revise e crie o resource

Deploy do modelo de embedding
- Abra o resource no Microsoft Foundry (portal classic, se for o fluxo que você estiver usando)
- Vá em Deployments → Deploy model → Deploy base model

- Escolha
text-embedding-3-small - Defina o deployment name — no exemplo usamos o mesmo nome do modelo:
text-embedding-3-small

- 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:
UseVector()noUseNpgsql— registra mapeamento do tipo e os translators de distância- 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:
- Azure OpenAI gera o embedding
- PostgreSQL + pgvector armazena e compara vetores (com HNSW para não varrer a tabela inteira)
- 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
vectore 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
- pgvector — extensão PostgreSQL
- pgvector-dotnet / EF Core — pacote .NET e exemplos de distância
- Azure OpenAI – gerar embeddings
- Criar resource e deploy Azure OpenAI
- Entity Framework Core
- Npgsql EF Core provider
- ASP.NET Core Minimal APIs
- PostgreSQL