Toda aplicação de RAG (Retrieval-Augmented Generation) precisa de um lugar para armazenar e buscar embeddings. A escolha do banco vetorial impacta latência, custo de infraestrutura, complexidade operacional e — o que menos se fala — a qualidade dos resultados da busca.
Este artigo cobre os três bancos vetoriais mais usados em projetos .NET: pgvector (extensão do PostgreSQL), Qdrant (banco vetorial open-source) e Pinecone (managed cloud). Para cada um: implementação em C#, casos de uso ideais e trade-offs reais de produção.
O que é um vector database e por que você precisa de um
Quando você gera um embedding de um texto — um vetor de 1.536 dimensões no caso do OpenAI, 1.024 no Cohere — você precisa armazená-lo de forma que consultas de similaridade sejam rápidas. Bancos relacionais convencionais não são eficientes para isso: fazer `SELECT * FROM docs ORDER BY cosine_similarity(embedding, $query)` em uma tabela com 500 mil linhas é impraticável.
Bancos vetoriais resolvem isso com algoritmos de approximate nearest neighbor (ANN) — estruturas de índice como HNSW (Hierarchical Navigable Small World) e IVF (Inverted File Index) que encontram os N vetores mais similares em tempo sublinear.
O HNSW é o algoritmo dominante hoje. Constrói um grafo em múltiplas camadas onde cada nó aponta para seus vizinhos mais próximos. A busca navega do nível superior (poucos nós, visão global) descendo para o nível inferior (todos os nós, precisão máxima). Resultado: busca em O(log n) com recall próximo de 100% para parâmetros bem calibrados.
pgvector: banco vetorial dentro do PostgreSQL que você já tem
pgvector é uma extensão do PostgreSQL que adiciona o tipo `vector` e suporte a índices HNSW e IVF. Se você já usa PostgreSQL, adicionar pgvector significa zero nova infraestrutura para começar.
Instalação e configuração
-- Habilitar extensão
CREATE EXTENSION IF NOT EXISTS vector;
-- Tabela de documentos com embedding
CREATE TABLE documents (
id BIGSERIAL PRIMARY KEY,
content TEXT NOT NULL,
metadata JSONB,
embedding vector(1536), -- dimensões do modelo OpenAI
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Índice HNSW (recomendado para a maioria dos casos)
CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
O parâmetro m define quantas conexões cada nó mantém no grafo. Valores maiores aumentam recall mas aumentam uso de memória e tempo de indexação. Para produção, comece com m=16 e ef_construction=64 — bom equilíbrio entre qualidade e performance.
Implementação em C# com Npgsql
// Package: Pgvector.EntityFrameworkCore
// dotnet add package Pgvector.EntityFrameworkCore
using Pgvector;
using Microsoft.EntityFrameworkCore;
public class Document
{
public long Id { get; set; }
public string Content { get; set; } = string.Empty;
public JsonDocument? Metadata { get; set; }
public Vector? Embedding { get; set; }
public DateTimeOffset CreatedAt { get; set; }
}
public class DocumentDbContext : DbContext
{
public DbSet<Document> Documents => Set<Document>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasPostgresExtension("vector");
modelBuilder.Entity<Document>()
.Property(d => d.Embedding)
.HasColumnType("vector(1536)");
}
}
public class VectorSearchService
{
private readonly DocumentDbContext _context;
private readonly IEmbeddingService _embeddingService;
public VectorSearchService(DocumentDbContext context, IEmbeddingService embeddingService)
{
_context = context;
_embeddingService = embeddingService;
}
public async Task IndexDocumentAsync(string content, object? metadata = null)
{
var embedding = await _embeddingService.GetEmbeddingAsync(content);
_context.Documents.Add(new Document
{
Content = content,
Metadata = metadata is null ? null : JsonDocument.Parse(JsonSerializer.Serialize(metadata)),
Embedding = new Vector(embedding),
});
await _context.SaveChangesAsync();
}
public async Task<List<Document>> SearchAsync(string query, int topK = 5)
{
var queryEmbedding = await _embeddingService.GetEmbeddingAsync(query);
var queryVector = new Vector(queryEmbedding);
// Cosine similarity com HNSW index
return await _context.Documents
.OrderBy(d => d.Embedding!.CosineDistance(queryVector))
.Take(topK)
.ToListAsync();
}
}
Quando usar pgvector
pgvector é a escolha certa quando:
- Você já usa PostgreSQL e quer evitar nova infraestrutura
- Precisa de transações ACID envolvendo documentos e embeddings
- O volume é menor que 1 milhão de vetores (HNSW no pgvector tem boa performance até esse range)
- Precisa de filtros complexos combinados com busca vetorial (usando índices compostos do Postgres)
- Hosting em Railway, Supabase, Neon ou qualquer managed PostgreSQL já suporta pgvector
Limitação importante: o índice HNSW do pgvector é carregado inteiramente na memória RAM durante a busca. Para 1 milhão de vetores de 1.536 dimensões com float32, isso significa ~6 GB só de índice. Planeje a memória do servidor antes de escalar.
Qdrant: banco vetorial open-source para performance e controle
Qdrant é um banco vetorial escrito em Rust, open-source (Apache 2.0), com client oficial para .NET. Ele foi projetado exclusivamente para busca vetorial — sem o compromisso do PostgreSQL de ser um banco relacional de uso geral.
Isso significa: índice HNSW mais eficiente em memória, suporte a quantização (redução de precisão para economizar memória), e filtros de payload nativos sem JOINs.
Setup com Docker
# docker-compose.yml
services:
qdrant:
image: qdrant/qdrant:latest
ports:
- "6333:6333"
volumes:
- qdrant_storage:/qdrant/storage
environment:
QDRANT__SERVICE__GRPC_PORT: "6334"
volumes:
qdrant_storage:
Implementação em C#
// Package: Qdrant.Client
// dotnet add package Qdrant.Client
using Qdrant.Client;
using Qdrant.Client.Grpc;
public class QdrantVectorService
{
private readonly QdrantClient _client;
private readonly IEmbeddingService _embeddingService;
private const string CollectionName = "documents";
public QdrantVectorService(QdrantClient client, IEmbeddingService embeddingService)
{
_client = client;
_embeddingService = embeddingService;
}
public async Task EnsureCollectionAsync()
{
var collections = await _client.ListCollectionsAsync();
if (collections.Any(c => c == CollectionName)) return;
await _client.CreateCollectionAsync(CollectionName, new VectorParams
{
Size = 1536,
Distance = Distance.Cosine,
// Quantização escalar: reduz memória em ~4x com perda mínima de recall
QuantizationConfig = new QuantizationConfig
{
Scalar = new ScalarQuantization
{
Type = QuantizationType.Int8,
AlwaysRam = true,
}
}
});
}
public async Task UpsertDocumentAsync(Guid id, string content, Dictionary<string, object> metadata)
{
var embedding = await _embeddingService.GetEmbeddingAsync(content);
var payload = metadata.ToDictionary(
kv => kv.Key,
kv => Value.ForString(kv.Value?.ToString() ?? string.Empty)
);
payload["content"] = Value.ForString(content);
await _client.UpsertAsync(CollectionName, new[]
{
new PointStruct
{
Id = new PointId { Uuid = id.ToString() },
Vectors = embedding,
Payload = { payload }
}
});
}
public async Task<List<ScoredPoint>> SearchAsync(
string query,
Dictionary<string, string>? filters = null,
int topK = 5)
{
var queryEmbedding = await _embeddingService.GetEmbeddingAsync(query);
// Filtros de payload — executados no índice, não em post-processing
Filter? filter = null;
if (filters is { Count: > 0 })
{
filter = new Filter
{
Must =
{
filters.Select(kv => new Condition
{
Field = new FieldCondition
{
Key = kv.Key,
Match = new Match { Text = kv.Value }
}
})
}
};
}
return await _client.SearchAsync(CollectionName,
vector: queryEmbedding,
filter: filter,
limit: (ulong)topK,
withPayload: true);
}
}
Quando usar Qdrant
- Volume entre 1 milhão e 100 milhões de vetores
- Precisa de filtros de metadados performáticos (filtered vector search nativo)
- Quer controle total sobre onde os dados ficam (on-premise ou cloud própria)
- Trabalha com multi-tenancy: named vectors e collections separadas por tenant
- Precisa de quantização para economizar RAM sem sacrificar recall
Pinecone: managed cloud sem operação
Pinecone é um banco vetorial totalmente gerenciado. Você não gerencia servidores, índices, ou escalabilidade. A troca: custo mais alto, dados em infraestrutura de terceiro, e vendor lock-in.
// Package: Pinecone.NET
// dotnet add package Pinecone.NET
using Pinecone;
public class PineconeVectorService
{
private readonly PineconeClient _client;
private readonly IEmbeddingService _embeddingService;
private const string IndexName = "documents";
public PineconeVectorService(PineconeClient client, IEmbeddingService embeddingService)
{
_client = client;
_embeddingService = embeddingService;
}
public async Task UpsertAsync(string id, string content, Dictionary<string, string> metadata)
{
var embedding = await _embeddingService.GetEmbeddingAsync(content);
var index = _client.Index(IndexName);
await index.UpsertAsync(new[]
{
new Vector
{
Id = id,
Values = embedding,
Metadata = new MetadataMap(
metadata.ToDictionary(
kv => kv.Key,
kv => (MetadataValue)kv.Value
)
)
}
});
}
public async Task<QueryResponse> SearchAsync(string query, int topK = 5)
{
var queryEmbedding = await _embeddingService.GetEmbeddingAsync(query);
var index = _client.Index(IndexName);
return await index.QueryAsync(new QueryRequest
{
Vector = queryEmbedding,
TopK = (uint)topK,
IncludeMetadata = true
});
}
}
Quando usar Pinecone
- Time sem expertise em infraestrutura para operar Qdrant self-hosted
- Protótipo ou MVP onde velocidade de entrega supera custo
- Volume muito grande (bilhões de vetores) onde escalabilidade automática é necessária
- LGPD e compliance não impedem dados em infra terceirizada (EUA)
Hybrid search: semântico + BM25
Busca puramente semântica falha em casos específicos: termos técnicos exatos (IDs de produto, códigos de erro, nomes próprios), queries muito curtas, e buscas onde a correspondência exata importa mais que o significado.
Hybrid search combina busca vetorial (semântica) com BM25 (texto exato) usando Reciprocal Rank Fusion (RRF) para unir os rankings.
public class HybridSearchService
{
private readonly QdrantVectorService _vectorSearch;
private readonly BM25SearchService _bm25Search;
// RRF: combina rankings de múltiplos sistemas de busca
// Score = sum(1 / (k + rank_i)) onde k=60 é constante padrão
public async Task<List<SearchResult>> SearchAsync(string query, int topK = 10)
{
var vectorResults = await _vectorSearch.SearchAsync(query, topK: topK * 2);
var bm25Results = await _bm25Search.SearchAsync(query, topK: topK * 2);
var rrfScores = new Dictionary<string, double>();
const int k = 60;
for (int i = 0; i < vectorResults.Count; i++)
{
var id = vectorResults[i].Id.ToString();
rrfScores.TryAdd(id, 0);
rrfScores[id] += 1.0 / (k + i + 1);
}
for (int i = 0; i < bm25Results.Count; i++)
{
var id = bm25Results[i].Id;
rrfScores.TryAdd(id, 0);
rrfScores[id] += 1.0 / (k + i + 1);
}
return rrfScores
.OrderByDescending(kv => kv.Value)
.Take(topK)
.Select(kv => new SearchResult { Id = kv.Key, Score = kv.Value })
.ToList();
}
}
Qdrant suporta hybrid search nativamente com sparse vectors. pgvector requer a implementação manual com tsvector do PostgreSQL para BM25. Pinecone também suporta sparse-dense nativo.
Estratégias de chunking que afetam mais o recall do que o modelo
A qualidade da busca vetorial depende muito de como você divide o texto antes de gerar os embeddings. Chunks mal feitos degradam o recall independente do banco ou modelo escolhido.
Estratégias em ordem crescente de sofisticação:
- Fixed-size chunking: divide por número de tokens (ex: 512 tokens com overlap de 64). Simples, funciona para textos homogêneos.
- Recursive text splitting: divide por parágrafos, depois frases, depois palavras — respeita a estrutura natural do texto. Implementação padrão do LangChain.
- Semantic chunking: calcula similaridade entre frases consecutivas. Quando a similaridade cai abaixo de um threshold, divide. Chunks semanticamente coesos.
- Hierarchical chunking: gera embeddings em múltiplos níveis (documento, seção, parágrafo). Busca no nível mais alto, recupera no nível mais baixo. Melhor para documentos longos.
Para a maioria dos projetos B2B com PDFs e documentos internos: recursive text splitting com chunks de 400-600 tokens e overlap de 80-100 tokens é o ponto de partida correto.
Comparativo final
| Critério | pgvector | Qdrant | Pinecone |
|---|---|---|---|
| Setup | PostgreSQL existente | Docker / cloud | SaaS, zero infra |
| Escala (vetores) | Até ~1M | 1M–100M+ | Ilimitado (pago) |
| Transações ACID | Sim (PostgreSQL) | Não | Não |
| Filtros de payload | Via SQL / índice composto | Nativo e performático | Nativo |
| Hybrid search | Manual (tsvector) | Nativo (sparse vectors) | Nativo |
| Custo de infra | Incluso no PostgreSQL | RAM + self-host ou cloud | Por vetor + queries |
| Controle de dados | Total | Total (self-host) | Dados na Pinecone Inc. |
| Quantização | Não | Sim (int8, binary) | Automática |
Conclusão: como escolher
Para a maioria dos projetos que estamos desenvolvendo hoje, a decisão é binária:
pgvector se você já tem PostgreSQL, o volume é razoável (<1M docs) e quer manter a stack simples. A observabilidade, o backup e a gestão de conexões já estão resolvidos — você não adiciona uma nova peça na infra.
Qdrant se a busca vetorial é core do produto, precisa de filtros performáticos em metadados, ou o volume exige quantização para controlar custos de RAM. É mais complexo de operar mas oferece mais controle.
Pinecone faz sentido em contextos específicos — protótipos rápidos ou escala muito alta sem capacidade de operação própria. Para projetos sérios com requisitos de compliance de dados no Brasil, a questão de dados hospedados fora do país precisa de análise jurídica.
A escolha do banco vetorial importa, mas importa menos do que a estratégia de chunking e a qualidade dos embeddings. Um sistema RAG ruim com pgvector e chunking bem feito vai superar um sistema com Pinecone e chunks de má qualidade.