Embeddings na prática: de texto para vetor para busca semântica

1. O que são embeddings e por que eles importam

Embeddings são representações densas de significado em vetores numéricos. Diferentemente das abordagens tradicionais como bag-of-words, que criam vetores esparsos onde cada posição representa uma palavra específica, os embeddings capturam relações semânticas em espaços contínuos de baixa dimensionalidade.

Um vetor bag-of-words para um documento com 10 mil palavras únicas teria 10 mil dimensões, majoritariamente preenchidas com zeros. Um embedding de qualidade, por outro lado, pode representar o mesmo documento em 384 ou 768 dimensões, com valores densos e contínuos.

As propriedades-chave dos embeddings incluem:
- Similaridade semântica: textos com significados próximos geram vetores próximos no espaço
- Operações algébricas: relações como "rei - homem + mulher ≈ rainha" funcionam no espaço vetorial
- Redução de dimensionalidade: compressão eficiente da informação semântica

# Exemplo conceitual de similaridade entre embeddings
"gato" → [0.23, 0.87, -0.12, 0.45, ...]
"felino" → [0.21, 0.85, -0.10, 0.48, ...]  # similaridade: 0.98
"carro" → [0.67, -0.34, 0.89, -0.12, ...]  # similaridade: 0.12

2. Como gerar embeddings a partir de texto

Modelos populares para geração de embeddings incluem Sentence-BERT (all-MiniLM-L6-v2), OpenAI Ada-002, e alternativas open-source como BGE e instructor-xl. O pipeline prático segue três etapas:

  1. Tokenização: converter texto em tokens numéricos
  2. Codificação: passar tokens pelo modelo transformer
  3. Normalização: ajustar o vetor resultante para comprimento unitário
# Pipeline de geração de embedding com Sentence-BERT
from sentence_transformers import SentenceTransformer

modelo = SentenceTransformer('all-MiniLM-L6-v2')
texto = "Embeddings transformam significado em vetores"

# Tokenização e codificação automáticas
vetor = modelo.encode(texto, normalize_embeddings=True)
print(f"Dimensões: {len(vetor)}")  # Saída: 384
print(f"Primeiros valores: {vetor[:5]}")
# Saída: [0.023, -0.045, 0.089, 0.012, -0.067]

Cuidados importantes: truncamento automático para 256 ou 512 tokens, padding em lote para processamento eficiente, e normalização para garantir comparabilidade.

3. Armazenando e indexando vetores em escala

Para milhões de documentos, o armazenamento plano de vetores é inviável. Vector databases como Pinecone, Weaviate e Qdrant oferecem índices aproximados para busca eficiente. Para soluções locais, FAISS e Annoy são as bibliotecas mais utilizadas.

Os principais índices de aproximação incluem:
- HNSW (Hierarchical Navigable Small World): excelente equilíbrio entre precisão e velocidade
- IVF (Inverted File Index): mais rápido para conjuntos muito grandes, com leve perda de precisão
- Produto Escalar vs. Cosseno: escolha baseada na normalização dos vetores

# Indexação com FAISS para busca aproximada
import faiss
import numpy as np

# Simulando 1000 embeddings de 384 dimensões
embeddings = np.random.random((1000, 384)).astype('float32')

# Construção do índice HNSW
dim = 384
indice = faiss.IndexHNSWFlat(dim, 32)  # 32 conexões por nó
indice.add(embeddings)

# Salvando o índice para uso futuro
faiss.write_index(indice, "indice_embeddings.faiss")

Estratégias de particionamento: dividir por domínio (ex: documentos financeiros vs. técnicos) ou usar sharding por hash do conteúdo para distribuição horizontal.

4. Busca semântica: do embedding à recuperação

A busca semântica opera em três etapas: converter a query em embedding, calcular similaridade com todos os documentos indexados, e retornar os top-k vizinhos mais próximos.

As métricas de similaridade mais comuns são:
- Cosseno: mede o ângulo entre vetores (padrão para embeddings normalizados)
- Distância Euclidiana: distância geométrica direta
- Produto Escalar: equivalente ao cosseno quando vetores são normalizados

# Busca semântica completa
from sentence_transformers import SentenceTransformer
import numpy as np

modelo = SentenceTransformer('all-MiniLM-L6-v2')

# Documentos indexados
documentos = [
    "Python é uma linguagem de programação versátil",
    "JavaScript é usado principalmente para web",
    "Embeddings representam significado em vetores"
]
embeddings_docs = modelo.encode(documentos, normalize_embeddings=True)

# Query do usuário
query = "linguagens de programação populares"
embedding_query = modelo.encode(query, normalize_embeddings=True)

# Cálculo de similaridade por cosseno
similaridades = np.dot(embeddings_docs, embedding_query)
indices_top3 = np.argsort(similaridades)[::-1][:3]

for i, idx in enumerate(indices_top3):
    print(f"{i+1}. {documentos[idx]} (score: {similaridades[idx]:.4f})")
# Saída:
# 1. Python é uma linguagem de programação versátil (score: 0.7823)
# 2. JavaScript é usado principalmente para web (score: 0.6541)
# 3. Embeddings representam significado em vetores (score: 0.1234)

Filtros híbridos combinam busca semântica com metadados: por exemplo, filtrar por data antes de calcular similaridade, ou usar filtros booleanos para restringir o escopo da busca.

5. Métricas e avaliação de qualidade

Avaliar a qualidade da busca semântica requer métricas específicas:

  • Recall@k: proporção de documentos relevantes recuperados entre os top-k
  • Precision@k: proporção de documentos relevantes entre os top-k retornados
  • Mean Reciprocal Rank (MRR): posição do primeiro resultado relevante
# Cálculo de métricas de avaliação
def recall_at_k(relevantes_recuperados, total_relevantes, k):
    return len(relevantes_recuperados[:k]) / min(total_relevantes, k)

def precision_at_k(relevantes_recuperados, k):
    return len(relevantes_recuperados[:k]) / k

# Exemplo: 3 documentos relevantes no total
relevantes = [0, 1, 2]  # índices dos documentos relevantes
recuperados = [0, 3, 1, 4, 2]  # ordem dos resultados

print(f"Recall@3: {recall_at_k(recuperados, len(relevantes), 3):.2f}")  # 0.67
print(f"Precision@3: {precision_at_k(recuperados, 3):.2f}")  # 0.67

Testes com queries de validação e ground truth manual são essenciais para identificar o modelo de embedding ideal para cada domínio.

6. Casos de uso práticos e armadilhas comuns

Casos reais:
- Busca em documentação técnica: embeddings permitem encontrar "Como instalar pacotes" mesmo quando o termo exato não aparece
- FAQ inteligente: respostas semanticamente similares a perguntas frequentes
- Recomendação de conteúdo: artigos relacionados por significado, não apenas por tags

Armadilhas frequentes:
- Viés do modelo: embeddings treinados em dados gerais podem falhar em domínios com jargão técnico
- Domínios específicos: termos como "kernel" (Linux vs. semente) exigem fine-tuning
- Idiomas: modelos multilíngues podem perder precisão em idiomas menos representados

# Fine-tuning simples para domínio específico
from sentence_transformers import SentenceTransformer, InputExample, losses
from torch.utils.data import DataLoader

modelo = SentenceTransformer('all-MiniLM-L6-v2')

# Pares de treinamento: (texto1, texto2, similaridade)
exemplos_treino = [
    InputExample(texts=["kernel Linux", "núcleo do sistema"], label=0.9),
    InputExample(texts=["kernel Linux", "semente de milho"], label=0.1),
]

dataloader = DataLoader(exemplos_treino, shuffle=True, batch_size=16)
perda = losses.CosineSimilarityLoss(modelo)
modelo.fit(train_objectives=[(dataloader, perda)], epochs=3)

7. Integração com LLMs: RAG e além

Embeddings são o backbone do Retrieval-Augmented Generation (RAG). O pipeline completo envolve:

  1. Indexação: converter documentos em embeddings e armazenar
  2. Busca: converter query em embedding e recuperar contexto relevante
  3. Prompt aumentado: combinar query original com documentos recuperados
  4. Geração: LLM responde com base no contexto enriquecido
# Pipeline RAG simplificado
from sentence_transformers import SentenceTransformer
import openai

modelo_emb = SentenceTransformer('all-MiniLM-L6-v2')
documentos = ["Embeddings capturam semântica", "RAG combina busca com geração"]

# Indexação
embeddings_docs = modelo_emb.encode(documentos)

# Busca
query = "Como funciona busca semântica?"
emb_query = modelo_emb.encode(query)
idx_relevante = np.argmax(np.dot(embeddings_docs, emb_query))

# Prompt aumentado
contexto = documentos[idx_relevante]
prompt = f"Contexto: {contexto}\nPergunta: {query}\nResposta:"

# Geração (simulada)
resposta = "A busca semântica usa embeddings para encontrar significado..."
print(resposta)

Monitoramento contínuo do índice é crucial: atualizações periódicas, re-embedding de documentos modificados, e validação de drift semântico garantem que o sistema mantenha qualidade ao longo do tempo.

Referências