Embeddings em C#: como uma frase acaba virando números
“Busca por similaridade semântica” soa mais abstrato do que precisa ser. No fim, o que está acontecendo é bem concreto: duas frases viram duas listas de números, e o sistema mede o quão parecidas essas duas listas são entre si.
O que é um embedding, sem rodeio
Um embedding é um vetor, uma lista ordenada de números de ponto flutuante, gerado por um modelo treinado especificamente para capturar significado de um texto nessa representação numérica. Frases com significado parecido produzem vetores próximos entre si nesse espaço multidimensional. Frases com significado bem diferente produzem vetores distantes.
“O cachorro correu no parque” e “O cão brincou na praça” deveriam gerar vetores relativamente próximos, mesmo sem compartilhar as mesmas palavras, porque o significado geral das duas frases é parecido. “O cachorro correu no parque” e “A ação da empresa caiu na bolsa” deveriam gerar vetores bem mais distantes, porque não há relação semântica relevante entre as duas.
Gerando um embedding em C#
Com Microsoft.Extensions.AI, gerar um embedding usa a mesma lógica de abstração já vista para chat e vector store: uma interface comum, implementada por diferentes provedores.
IEmbeddingGenerator<string, Embedding<float>> generator = new OpenAIClient(apiKey)
.AsEmbeddingGenerator("text-embedding-3-small");
var embedding = await generator.GenerateAsync("O cachorro correu no parque");
float[] vetor = embedding.Vector.ToArray();
vetor é, nesse ponto, só um array de números. Não tem nada de especial em olhar para ele diretamente, a informação útil só aparece quando ele é comparado a outro vetor gerado da mesma forma.
Similaridade de cosseno: medindo o ângulo entre dois vetores
A forma mais comum de comparar dois embeddings é a similaridade de cosseno, que mede o cosseno do ângulo entre os dois vetores no espaço multidimensional, ignorando a magnitude de cada um e focando apenas na direção. O resultado varia de -1 a 1: valores próximos de 1 indicam vetores apontando praticamente na mesma direção (alta similaridade semântica), valores próximos de 0 indicam pouca relação, e valores negativos indicam direções opostas.
static float SimilaridadeDeCosseno(float[] a, float[] b)
{
float produtoEscalar = 0f, normaA = 0f, normaB = 0f;
for (int i = 0; i < a.Length; i++)
{
produtoEscalar += a[i] * b[i];
normaA += a[i] * a[i];
normaB += b[i] * b[i];
}
return produtoEscalar / (MathF.Sqrt(normaA) * MathF.Sqrt(normaB));
}
O produto escalar (produtoEscalar) soma a multiplicação de cada par de componentes correspondentes dos dois vetores. Dividir isso pelo produto das normas (o “comprimento” de cada vetor) normaliza o resultado, removendo o efeito de um vetor simplesmente ser maior que o outro em magnitude, deixando só a direção relativa entre os dois pesar no resultado final.
Por que normalizar direção importa mais que magnitude
Dois vetores podem apontar exatamente para a mesma direção e ainda assim ter magnitudes bem diferentes, dependendo de detalhes da forma como o modelo gerou aquela representação para cada texto específico. Se a comparação usasse só a distância bruta entre os vetores (magnitude incluída), textos semanticamente idênticos, mas com embeddings de magnitude diferente, poderiam parecer mais distantes do que realmente são em termos de significado. Cosseno resolve isso perguntando só “esses dois vetores apontam para o mesmo lugar”, não “esses dois vetores têm o mesmo tamanho”.
O que isso permite construir
Com uma função de similaridade de cosseno e uma coleção de embeddings já gerados, dá para responder perguntas como “quais desses cem documentos são mais parecidos com essa pergunta específica”, comparando o embedding da pergunta contra o embedding de cada documento e ordenando pelo resultado. É exatamente esse mecanismo, aplicado em escala e otimizado por um vector store, que sustenta a etapa de busca de um pipeline RAG. A matemática por trás, no entanto, cabe inteira nesse laço de poucas linhas acima.