Capítulo 6: Embeddings e Representação Semântica
Nos capítulos anteriores, mencionamos embeddings em diversos contextos: no Capítulo 1, vimos como Transformers geram representações vetoriais através de camadas de atenção; no Capítulo 3, discutimos como fine-tuning ajusta esses vetores para tarefas específicas; no Capítulo 4, utilizamos embeddings implicitamente quando LLMs processam prompts. Agora é hora de aprofundar: como essas representações vetoriais realmente funcionam, como escolher entre dezenas de modelos disponíveis, e como construir sistemas de busca e recuperação que alimentarão os agentes da Parte II.
Neste capítulo, exploraremos o conceito de embeddings em profundidade e suas aplicações práticas, passando desde o que são embeddings, suas propriedades matemáticas, até a arquitetura dos modelos mais avançados disponíveis hoje.
O Que São Embeddings?
Embeddings são representações vetoriais densas que codificam significado semântico. Enquanto nos capítulos anteriores focamos em como Transformers processam e geram texto, este capítulo foca em como usar essas representações para tarefas críticas em sistemas de agentes: busca semântica, recuperação de documentos relevantes, e seleção dinâmica de ferramentas.
Da Esparsidade à Densidade: Uma Breve Retrospectiva
Para apreciar a elegância dos embeddings modernos, vale revisitar brevemente suas predecessoras. Nas abordagens clássicas de NLP, como one-hot encoding, cada palavra era representada por um vetor esparso (espalhado aqui e ali) onde apenas uma posição tinha valor 1 e todas as outras eram 0. Para um vocabulário de 50.000 palavras, isso significava vetores de 50.000 dimensões, onde 49.999 posições eram sempre zero. Essa representação era não apenas computacionalmente ineficiente, mas fundamentalmente limitada: as palavras “rei” e “monarca” eram tão diferentes quanto “rei” e “banana”, não havia noção de similaridade semântica.
TF-IDF (Term Frequency-Inverse Document Frequency) representou um avanço ao considerar a importância relativa das palavras em um corpus (Spärck Jones 1972). Palavras comuns como “o” ou “de” recebiam pesos baixos, enquanto termos mais específicos tinham maior peso. No entanto, mesmo TF-IDF permanecia preso em representações esparsas e não capturava relações semânticas complexas.
Os embeddings densos, popularizados pelo Word2Vec (Mikolov et al. 2013), revolucionaram o campo ao comprimir informação semântica em vetores densos de dimensões muito menores - tipicamente 256, 512 ou 768 dimensões. A magia dos embeddings está em sua capacidade de capturar significado através de proximidade geométrica: palavras semanticamente relacionadas ocupam regiões próximas no espaço vetorial, enquanto conceitos distintos ficam distantes.
Propriedades Emergentes: Aritmética Semântica
A característica mais fascinante dos embeddings modernos é a emergência de propriedades algébricas que espelham relações conceituais. O exemplo canônico, demonstrado originalmente por Mikolov et al. (Mikolov et al. 2013), é a relação vetorial:
vec("rei") - vec("homem") + vec("mulher") ≈ vec("rainha")
Essa propriedade não foi explicitamente programada, ela emergiu naturalmente do processo de treinamento. O modelo aprendeu que a diferença entre “rei” e “homem” codifica o conceito de realeza, e essa diferença pode ser transferida para o domínio feminino. Vejamos isso em prática:
from sentence_transformers import SentenceTransformer
import numpy as np
model = SentenceTransformer('all-MiniLM-L6-v2')
# Codificar palavras em vetores
king = model.encode("rei")
man = model.encode("homem")
woman = model.encode("mulher")
queen = model.encode("rainha")
# Operação vetorial: rei - homem + mulher
result = king - man + woman
# Calcular similaridade com "rainha"
similarity = np.dot(result, queen) / (np.linalg.norm(result) * np.linalg.norm(queen))
print(f"Similaridade: {similarity:.4f}") # Tipicamente > 0.7Outras relações emergentes incluem geografia (“Paris” - “França” + “Itália” ≈ “Roma”), tempo verbal, plural/singular, e até relações mais abstratas como intensidade emocional.
Dimensionalidade e Trade-offs
A escolha da dimensionalidade dos embeddings envolve trade-offs entre expressividade e eficiência. Vetores de maior dimensão podem capturar nuances semânticas mais sutis, mas a um custo computacional e de memória significativo. Na prática, observamos os seguintes padrões (Neelakantan et al. 2022):
Dimensões baixas (128-512): Adequadas para tarefas simples de classificação ou quando recursos computacionais são limitados. Modelos como all-MiniLM-L6-v2 (384 dimensões) oferecem excelente custo-benefício para aplicações práticas.
Dimensões médias (512-1536): O sweet spot para a maioria das aplicações de produção. Modelos como BERT-base (768 dimensões) e OpenAI text-embedding-3-small (1536 dimensões) operam nessa faixa, equilibrando qualidade e performance.
Dimensões altas (1536-3072): Reservadas para aplicações que demandam máxima precisão semântica, como busca em documentos técnicos especializados ou quando o custo computacional não é limitante. OpenAI text-embedding-3-large (3072 dimensões) exemplifica essa categoria.
O impacto da dimensionalidade não é apenas computacional, ele afeta a capacidade do modelo de generalizar. Dimensões excessivas podem levar a overfitting, onde o modelo memoriza o conjunto de treino mas falha em generalizar para novos exemplos. Esse fenômeno é particularmente relevante ao fazer fine-tuning de embeddings em datasets pequenos.
Para quantificar esse trade-off, considere que aumentar dimensionalidade de 384 para 768 dobra o uso de memória e aproximadamente duplica o tempo de inferência, mas tipicamente melhora performance em benchmarks de similaridade semântica em apenas 5-10% (Muennighoff et al. 2023). A decisão deve ser guiada por requisitos específicos da aplicação e requisitos/limitações de infraestrutura.
Arquitetura de Modelos de Embedding
Da Estática ao Contexto
Word2Vec (Mikolov et al. 2013) marcou um ponto de virada no processamento de linguagem natural ao demonstrar que embeddings densos podiam ser aprendidos de forma não-supervisionada a partir de grandes corpora. A arquitetura oferecia duas abordagens complementares: CBOW (Continuous Bag of Words), que previa uma palavra dado seu contexto, e Skip-gram, que fazia o oposto, previa o contexto dada uma palavra central.
O treinamento de Word2Vec era computacionalmente elegante. Para cada palavra em uma janela deslizante de texto, o modelo ajustava vetores para maximizar a probabilidade de prever corretamente palavras vizinhas. Essa tarefa aparentemente simples forçava o modelo a aprender representações onde palavras com contextos similares recebiam vetores próximos.
No entanto, Word2Vec possuía uma limitação fundamental: cada palavra tinha exatamente um embedding fixo, independente do contexto. A palavra “banco” recebia o mesmo vetor em “banco de dados” e “banco do parque”, apesar dos significados completamente distintos. Essa limitação levou ao desenvolvimento de embeddings contextualizados.
Embeddings Contextualizados
A arquitetura Transformer, que estudamos em profundidade no Capítulo 1, resolveu o problema de polissemia através de seu mecanismo de atenção. Ao invés de um único vetor estático por palavra, Transformers geram embeddings dinâmicos que dependem do contexto completo da sentença.
Considere a arquitetura BERT (Devlin et al. 2018), que revolucionou embeddings contextualizados. Quando processamos “O banco de dados está corrompido”, o embedding para “banco” é computado levando em conta toda a sentença através de múltiplas camadas de self-attention. O modelo atende fortemente para “dados” e “corrompido”, resultando em um vetor que codifica o significado técnico. Para “Sentei no banco do parque”, o mesmo token “banco” resulta em um vetor completamente diferente, influenciado por “sentei” e “parque”.
Matematicamente, em cada camada do Transformer, o embedding de um token é recalculado como uma combinação ponderada dos embeddings de todos os outros tokens:
h_i^(l) = Attention(h_i^(l-1), H^(l-1))
Onde h_i^(l) é o hidden state do token i na camada l, e H^(l-1) representa todos os hidden states da camada anterior. Essa recombinação iterativa através de múltiplas camadas permite que informação contextual se propague e refine cada representação.
Token-Level vs. Sentence-Level
Transformers naturalmente produzem embeddings no nível de token, cada subword no vocabulário do tokenizer recebe seu próprio vetor contextualizado. Para a sentença “Claude é um modelo de linguagem”, com 6 tokens, BERT produziria 6 vetores distintos de 768 dimensões cada.
No entanto, muitas aplicações práticas requerem um único vetor representando a sentença inteira. Por exemplo, ao buscar documentos similares ou comparar significado de frases completas. Essa necessidade nos leva às pooling strategies.
Pooling Strategies: Agregando Informação
Pooling strategies são métodos para agregar múltiplos token embeddings em um único sentence embedding. Levando em conta que cada sentença pode variar em comprimento (número de tokens), precisamos de uma forma consistente de condensar essa informação variável em um vetor fixo.
Três abordagens dominam a prática de agregar embeddings token-level em sentence-level:
CLS Token Pooling: BERT introduziu um token especial [CLS] no início de cada sentença. Durante o treinamento em tarefas de classificação, o modelo aprende a concentrar informação relevante sobre a sentença inteira nesse token. Para obter um sentence embedding, simplesmente extraímos o vetor do [CLS] token:
from transformers import AutoTokenizer, AutoModel
import torch
tokenizer = AutoTokenizer.from_pretrained('bert-base-uncased')
model = AutoModel.from_pretrained('bert-base-uncased')
text = "Claude é um modelo de linguagem"
inputs = tokenizer(text, return_tensors='pt')
outputs = model(**inputs)
# CLS token é sempre a primeira posição
cls_embedding = outputs.last_hidden_state[0, 0, :] # Shape: [768]Mean Pooling: Calcula a média de todos os token embeddings, excluindo tokens de padding. Essa abordagem tende a funcionar melhor para embeddings semânticos, pois considera informação de toda a sentença uniformemente:
def mean_pooling(model_output, attention_mask):
"""Average de todos os tokens, excluindo padding."""
token_embeddings = model_output[0] # [batch_size, seq_len, hidden_dim]
# Expandir attention_mask para multiplicação
input_mask_expanded = attention_mask.unsqueeze(-1).expand(token_embeddings.size()).float()
# Somar embeddings e dividir pelo número real de tokens
return torch.sum(token_embeddings * input_mask_expanded, 1) / torch.clamp(input_mask_expanded.sum(1), min=1e-9)Mean pooling é a estratégia padrão em Sentence Transformers, família de modelos otimizados especificamente para gerar sentence embeddings de alta qualidade.
Max Pooling: Seleciona o valor máximo em cada dimensão através de todos os tokens. Essa abordagem pode capturar features distintivas específicas, mas tende a ser menos robusta que mean pooling para embeddings semânticos:
def max_pooling(model_output, attention_mask):
"""Máximo em cada dimensão através de todos os tokens."""
token_embeddings = model_output[0]
# Aplicar mask: -inf em posições de padding
input_mask_expanded = attention_mask.unsqueeze(-1).expand(token_embeddings.size()).float()
token_embeddings[input_mask_expanded == 0] = -1e9
return torch.max(token_embeddings, 1)[0]A escolha da estratégia de pooling tem impacto significativo na qualidade dos embeddings resultantes. Reimers e Gurevych (2019) demonstraram que, para tarefas de similaridade semântica, mean pooling com modelos fine-tuned supera CLS token pooling em até 5 pontos percentuais em benchmarks padrão. No entanto, para tarefas de classificação, CLS token pooling mantém vantagem por ter sido explicitamente treinado para essa finalidade durante o pré-treinamento de BERT.
Modelos de Embedding Modernos
Sentence Transformers: Otimizados para Similaridade Semântica
BERT revolucionou NLP, mas não foi originalmente otimizado para gerar embeddings de sentenças comparáveis. Quando usamos BERT vanilla com CLS pooling para medir similaridade entre duas frases, os resultados frequentemente decepcionam - sentenças semanticamente similares podem ter embeddings distantes no espaço vetorial.
Sentence-BERT (SBERT) (Reimers e Gurevych 2019) resolve essa limitação através de uma arquitetura bi-encoder treinada com contrastive learning. Durante o treinamento, o modelo processa pares de sentenças similares e dissimilares, aprendendo a mapear sentenças semanticamente próximas para regiões vizinhas do espaço vetorial.
A arquitetura bi-encoder é computacionalmente eficiente para busca em larga escala. Ao invés de processar cada par de sentenças juntas (abordagem cross-encoder, que seria O(n²) para n documentos), SBERT codifica cada sentença independentemente uma vez, permitindo comparações posteriores através de simples operações vetoriais. Para um corpus de 1 milhão de documentos, isso reduz o custo de encontrar documentos similares de bilhões de forward passes para apenas 1 milhão de encodings + operações de similaridade rápidas.
Cross-Encoders: Alta Qualidade para Re-ranking
Enquanto bi-encoders priorizam eficiência, cross-encoders sacrificam velocidade por máxima qualidade. A diferença arquitetural é fundamental: ao invés de codificar query e documento separadamente, um cross-encoder processa ambos juntos como uma única entrada concatenada, permitindo que o modelo capture interações profundas entre cada token da query e cada token do documento através de attention cross-modal.
A vantagem é significativa. Em benchmarks de Semantic Textual Similarity (STS), cross-encoders tipicamente superam bi-encoders em 5-10 pontos percentuais. Para a query “tratamento de diabetes tipo 2”, um bi-encoder pode ranquear documentos baseado apenas em overlap semântico geral, enquanto um cross-encoder pode capturar que “tipo 2” especificamente se relaciona com “resistência à insulina” no documento, não apenas “diabetes” genericamente.
O custo é proibitivo para busca direta. Para comparar uma query contra 1 milhão de documentos, um cross-encoder requer 1 milhão de forward passes, cada um processando query + documento juntos. Em hardware típico, isso leva minutos, não milissegundos. A solução prática é re-ranking em dois estágios: use um bi-encoder rápido para recuperar os top-100 candidatos (~10-50ms), depois refine apenas esses 100 com um cross-encoder (~200-500ms total). Esse pipeline híbrido captura 95-98% da qualidade de um cross-encoder puro a fração do custo computacional.
from sentence_transformers import SentenceTransformer, CrossEncoder
import numpy as np
# Estágio 1: Retrieval rápido com bi-encoder
bi_encoder = SentenceTransformer('all-mpnet-base-v2')
query = "Como prevenir doenças cardiovasculares?"
# Encode documentos uma vez (offline)
doc_embeddings = bi_encoder.encode(documents, normalize_embeddings=True)
# Busca rápida: top-100 candidatos
query_embedding = bi_encoder.encode([query], normalize_embeddings=True)
similarities = np.dot(query_embedding, doc_embeddings.T)[0]
top_100_indices = np.argsort(similarities)[-100:][::-1]
top_100_docs = [documents[i] for i in top_100_indices]
# Estágio 2: Re-ranking com cross-encoder
cross_encoder = CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')
query_doc_pairs = [[query, doc] for doc in top_100_docs]
cross_scores = cross_encoder.predict(query_doc_pairs)
# Ordenar por cross-encoder scores
reranked_indices = np.argsort(cross_scores)[::-1]
final_results = [top_100_docs[i] for i in reranked_indices[:10]]Quando vale a pena adicionar re-ranking? Quando Precision@5 do bi-encoder está entre 0.60-0.75 - suficiente para recall inicial mas com espaço para melhoria, quando latências de 200-500ms são aceitáveis, tipicamente em aplicações não-interativas ou onde qualidade supera velocidade, e especialmente em domínios especializados onde nuances semânticas são críticas, como medicina, direito ou pesquisa científica. Para aplicações que já atingem >0.80 Precision@5 com bi-encoder, o ganho marginal raramente justifica a complexidade adicional.
Precision@5 é uma métrica que mede a precisão dos 5 primeiros resultados de um sistema de busca.
Precision@5 = (Número de documentos relevantes nos top-5) / 5
Valores típicos:
- > 0.70: Sistema aceitável para produção
- > 0.80: Sistema de boa qualidade
- > 0.85: Sistema de alta qualidade
Família Sentence Transformers
A família Sentence Transformers é um conjunto diversificado de modelos pré-treinados e fine-tuned para gerar embeddings de alta qualidade. A Hugging Face oferece modelos especializados para diferentes desafios técnicos e domínios. Alguns dos mais populares incluem:
all-MiniLM-L6-v2 (384 dimensões): O “canivete suíço” de embeddings. Com apenas 80MB, oferece excelente qualidade para a maioria das tarefas. Ideal para aplicações com constraints de latência ou quando embeddings serão armazenados em memória. Processa ~5000 sentenças/segundo em CPU.
all-mpnet-base-v2 (768 dimensões): O melhor modelo de propósito geral da família. Baseado em MPNet (Masked and Permuted Pre-training), supera all-MiniLM em ~3-5 pontos percentuais em benchmarks, ao custo de ser 3x mais lento. Recomendado quando qualidade é prioritária sobre latência.
multi-qa-MiniLM-L6-cos-v1 (384 dimensões): Fine-tuned especificamente para question-answering. Excelente para sistemas de busca onde queries são perguntas naturais e documentos são respostas ou conteúdo informativo.
Modelos Multilíngues e Especializados
multilingual-e5-large: Suporta 100+ línguas com qualidade consistente. Essencial para aplicações globais onde documentos em múltiplos idiomas devem ser comparáveis. Sentenças equivalentes em português e inglês produzem embeddings próximos no espaço vetorial, permitindo busca cross-lingual.
jina-embeddings-v2-base-code (768 dimensões): Especializado em código. Entende não apenas sintaxe, mas semântica de programação - funções que implementam o mesmo algoritmo em linguagens diferentes recebem embeddings similares. Crucial para sistemas de busca em repositórios de código ou documentação técnica.
nomic-embed-text-v1.5 (768 dimensões): Treinado com context length de 8192 tokens, 4-8x maior que modelos tradicionais. Permite embedding de documentos longos inteiros sem chunking, preservando contexto global. Trade-off: inferência 3-4x mais lenta.
Comparação Quantitativa Entre Modelos
A escolha do modelo de embedding é uma das decisões arquiteturais mais impactantes em sistemas de agentes. Vejamos dados concretos em três dimensões críticas:
| Modelo | MTEB Score | Latência (ms)* | Custo ($/1M docs)** |
|---|---|---|---|
| all-MiniLM-L6-v2 | 56.3 | 12 | $0 (self-hosted) |
| all-mpnet-base-v2 | 63.3 | 35 | $0 (self-hosted) |
| text-embedding-3-small | 62.3 | 80 | $20 |
| text-embedding-3-large | 64.6 | 95 | $130 |
| multilingual-e5-large | 64.2 | 45 | $0 (self-hosted) |
* Latência média por documento de ~100 palavras, CPU Intel Xeon ou equivalente
** Custo assumindo ~750 palavras/documento, incluindo custos de infraestrutura para self-hosted
MTEB (Massive Text Embedding Benchmark) (Muennighoff et al. 2023) agrega performance em 58 datasets cobrindo classificação, clustering, reranking, retrieval, semantic textual similarity, e outras tarefas. Pontuações acima de 60 são consideradas estado da arte.
A decisão sobre qual modelo usar deve considerar seu contexto específico. Para protótipos e MVPs onde velocidade de desenvolvimento é prioritária, all-MiniLM-L6-v2 oferece excelente relação qualidade-tempo, permitindo iterações rápidas sem comprometer resultados significativamente. Quando o projeto evolui para produção com budget limitado e infraestrutura self-hosted, all-mpnet-base-v2 emerge como o sweet spot, otimizando qualidade por custo e oferecendo performance robusta sem dependências de APIs externas. Em cenários onde cada ponto percentual de precisão pode impactar resultados críticos do negócio e há budget disponível, text-embedding-3-large justifica seu custo premium com a máxima qualidade disponível. Para aplicações que precisam operar através de múltiplos idiomas mantendo consistência semântica, multilingual-e5-large é imbatível no cenário self-hosted, capturando nuances linguísticas sem degradação cross-lingual. Finalmente, quando latência crítica sub-5ms por documento é um requisito não-negociável (como em sistemas de recomendação real-time), all-MiniLM-L6-v2 combinado com quantização pode atender essa demanda sem sacrificar demasiadamente a qualidade.
Medidas de Similaridade
Uma vez que temos embeddings, a questão torna-se: como medir o quão “próximas” duas representações vetoriais estão? A escolha da métrica de similaridade não é mera tecnicidade, ela define o que significa “documentos relacionados” em seu sistema.
Similaridade Cosseno: A Métrica Padrão
Similaridade cosseno mede o ângulo entre dois vetores, ignorando suas magnitudes. Para vetores A e B:
cos_sim(A, B) = (A · B) / (||A|| * ||B||)
A intuição geométrica é: vetores apontando na mesma direção (ângulo próximo de 0°) têm similaridade próxima de 1, vetores ortogonais (90°) têm similaridade 0, e vetores opostos (-180°) têm similaridade -1.
Por que cosseno domina em NLP? Porque a direção de um embedding captura seu significado semântico, enquanto a magnitude frequentemente reflete apenas características acidentais como comprimento do documento. Considere duas descrições do mesmo produto, uma com 50 palavras e outra com 500. Seus embeddings apontarão na mesma direção (mesmo significado), mas terão magnitudes diferentes. Similaridade cosseno corretamente os identifica como similares.
import numpy as np
from numpy.linalg import norm
def cosine_similarity(a, b):
"""
Calcula similaridade cosseno entre dois vetores.
Args:
a, b: arrays numpy de mesma dimensão
Returns:
float entre -1 e 1, onde 1 = vetores idênticos em direção
"""
return np.dot(a, b) / (norm(a) * norm(b))
# Exemplo concreto
doc1 = np.array([0.5, 0.8, 0.2]) # Embedding do documento 1
doc2 = np.array([0.6, 0.7, 0.3]) # Embedding similar (direção próxima)
doc3 = np.array([-0.5, -0.8, -0.2]) # Embedding oposto
print(f"sim(doc1, doc2): {cosine_similarity(doc1, doc2):.4f}") # ~0.98 (muito similar)
print(f"sim(doc1, doc3): {cosine_similarity(doc1, doc3):.4f}") # ~-1.0 (oposto)Para uma implementação completa comparando múltiplas métricas de similaridade (cosseno, euclidiana, Manhattan) com demonstrações de normalização, veja o Exemplo 1: Comparação de Métricas de Similaridade.
Otimização importante: Quando embeddings são L2-normalizados (||v|| = 1), similaridade cosseno simplifica para produto interno, economizando operações de divisão caras. Sentence Transformers e OpenAI embeddings frequentemente retornam vetores já normalizados.
Distância Euclidiana: Quando Magnitude Importa
Distância euclidiana mede o comprimento direto entre dois pontos no espaço vetorial:
euclidean(A, B) = ||A - B|| = sqrt(Σ(a_i - b_i)²)
Diferente de cosseno, distância euclidiana considera tanto direção quanto magnitude. Dois vetores na mesma direção mas com magnitudes diferentes terão similaridade cosseno alta mas distância euclidiana significativa.
Quando usar euclidiana? Principalmente em espaços onde magnitude carrega informação semântica. Por exemplo, em embeddings de intensidade emocional, um vetor maior pode legitimamente representar emoção mais forte. Em embeddings de frequência de palavras não-normalizados, magnitude reflete prevalência, que pode ser relevante.
def euclidean_distance(a, b):
"""
Calcula distância euclidiana entre dois vetores.
Valores menores = mais similar. Range: [0, ∞)
"""
return norm(a - b)
# Mesmo exemplo anterior
print(f"dist(doc1, doc2): {euclidean_distance(doc1, doc2):.4f}") # ~0.21 (próximos)
print(f"dist(doc1, doc3): {euclidean_distance(doc1, doc3):.4f}") # ~1.94 (distantes)
# Converter distância para similaridade (0 = idêntico, 1 = infinitamente distante)
def distance_to_similarity(dist):
return 1 / (1 + dist)Distância Manhattan (L1) é uma variante que soma diferenças absolutas ao invés de quadráticas: manhattan(A, B) = Σ|a_i - b_i|. Também conhecida como “city block distance” ou “taxicab distance”, ela mede distância como se você estivesse navegando em uma grade de ruas. Computacionalmente mais barata que Euclidiana (não requer operações de raiz quadrada), Manhattan é menos comum em embeddings densos de alta dimensão, mas encontra uso em contextos específicos: features esparsas, dados categóricos encodados como one-hot, ou quando simplicidade computacional é crítica e pode-se tolerar aproximações mais grosseiras de similaridade.
Produto Interno (Dot Product): Eficiência com Normalização
Produto interno é simplesmente: dot(A, B) = Σ(a_i * b_i). Quando vetores são L2-normalizados, produto interno é equivalente a similaridade cosseno, mas muito mais eficiente:
# Com vetores normalizados, estas são equivalentes:
cos_sim = cosine_similarity(a_normalized, b_normalized) # Requer 2 normas + divisão
dot_sim = np.dot(a_normalized, b_normalized) # Apenas multiplicação e soma
# Para 1M comparações, diferença de 10-20% em tempo de execuçãoMuitos sistemas de vector databases (como FAISS e Pinecone) otimizam especificamente para dot product, fazendo uso de instruções SIMD (Single Instruction, Multiple Data) de CPU/GPU. Ao escolher dot product como métrica, você habilita essas otimizações.
E Qual Métrica Usar?
Similaridade cosseno, ou equivalentemente dot product com vetores normalizados, deve ser sua escolha padrão ao trabalhar com embeddings de modelos de linguagem modernos, especialmente quando documentos de diferentes comprimentos precisam ser comparáveis e o significado semântico é mais importante que features específicas, como ocorre tipicamente em busca semântica em documentação técnica.
Distância euclidiana torna-se apropriada em situações onde a magnitude dos embeddings carrega informação relevante para a aplicação, particularmente ao trabalhar com embeddings não-normalizados onde escala importa ou em tarefas de clustering onde distância absoluta precisa ser interpretável, como ao agrupar representações de intensidade emocional onde magnitude pode indicar força do sentimento.
Manhattan distance justifica-se quando se necessita de máxima eficiência computacional e pode-se sacrificar alguma precisão, ou quando trabalhando em espaços de baixa dimensão onde L1 mantém interpretabilidade clara, situações típicas ao processar features numéricas esparsas ou one-hot encodings onde operações mais simples já são suficientes.
Impacto da Normalização
Além da escolha da métrica de similaridade, outra decisão fundamental é se normalizar os embeddings antes de compará-los. Normalizar significa transformar cada vetor para que tenha magnitude 1 (||v|| = 1), mantendo apenas sua direção. Essa escolha aparentemente simples tem consequências profundas na interpretação de similaridade e no comportamento do sistema de busca:
def l2_normalize(v):
"""L2 normalization: força ||v|| = 1"""
return v / norm(v)
# Embeddings originais
emb1 = np.array([1.0, 2.0, 3.0]) # ||emb1|| = 3.74
emb2 = np.array([0.1, 0.2, 0.3]) # ||emb2|| = 0.37 (mesma direção, magnitude 10x menor)
# Sem normalização
print(f"dot(emb1, emb2) = {np.dot(emb1, emb2):.4f}") # 1.4 (influenciado por magnitude)
# Com normalização
emb1_norm = l2_normalize(emb1)
emb2_norm = l2_normalize(emb2)
print(f"dot(emb1_norm, emb2_norm) = {np.dot(emb1_norm, emb2_norm):.4f}") # 1.0 (idênticos)Modelos modernos (Sentence Transformers com normalize_embeddings=True, OpenAI embeddings) frequentemente retornam vetores já normalizados. Se você fine-tunar seu próprio modelo, normalização deve ser uma escolha consciente baseada em se magnitude é semanticamente relevante em seu domínio.
Chunking: Estratégias de Segmentação
Documentos no mundo real raramente cabem confortavelmente nas limitações técnicas de modelos de embedding. Um manual técnico de 50 páginas, um artigo científico, ou mesmo um README extenso excedem os context windows típicos de 512 tokens (BERT-base) ou 8192 tokens (modelos long-context modernos). Chunking, dividir documentos longos em segmentos processáveis, não é opcional, é fundamental para sistemas de produção.
Chunking conecta-se diretamente aos conceitos de tokenização discutidos nos Capítulos 1 e 2. A escolha do tokenizer (BPE, WordPiece, Unigram) impacta quantos tokens um chunk conterá - o mesmo texto gera diferentes contagens de tokens dependendo do vocabulário do modelo. Além disso, as limitações de context window exploradas no Capítulo 1 (512 tokens para BERT, 8K+ para modelos modernos) são os constraints fundamentais que tornam chunking necessário. Ao projetar sua estratégia de chunking, sempre considere qual tokenizer seu modelo de embedding usa para garantir chunks que não excedam os limites arquiteturais.
A qualidade do chunking impacta diretamente a eficácia da recuperação. Chunks muito grandes diluem informação relevante com contexto irrelevante, reduzindo precisão da busca. Chunks muito pequenos fragmentam conceitos relacionados, perdendo contexto necessário para compreensão. A estratégia de chunking é tão importante quanto a escolha do modelo de embedding.
Fixed-Size Chunking: Simplicidade com Limitações
A abordagem mais direta é dividir texto em blocos de tamanho fixo, por exemplo, 512 tokens por chunk. Essa estratégia atrai pela simplicidade de implementação e previsibilidade, você sempre sabe exatamente quantos chunks um documento gerará e quanto espaço ocuparão. No entanto, essa rigidez vem com trade-offs significativos que podem comprometer a qualidade da recuperação em documentos com estrutura complexa.
def fixed_size_chunking(text, chunk_size=512, overlap=50):
"""
Divide texto em chunks de tamanho fixo.
Args:
text: string do documento completo
chunk_size: número de tokens por chunk
overlap: tokens compartilhados entre chunks consecutivos
"""
tokens = tokenizer.encode(text)
chunks = []
for i in range(0, len(tokens), chunk_size - overlap):
chunk_tokens = tokens[i:i + chunk_size]
chunks.append(tokenizer.decode(chunk_tokens))
return chunksO parâmetro overlap é bem importante aqui, sem ele, informação que cruza boundaries entre chunks seria perdida. Com overlap de 10-20%, um conceito que começa no fim de um chunk e termina no início do próximo aparecerá completo em pelo menos um chunk.
Problemas do fixed-size:
- Corta sentenças e parágrafos arbitrariamente, quebrando unidades semânticas naturais
- Tamanho ótimo varia radicalmente por domínio (código vs prosa vs listas)
- Não considera estrutura do documento (seções, capítulos, listas)
Fixed-size funciona aceitavelmente para texto altamente homogêneo (transcrições, chat logs), mas falha em documentação estruturada.
Sentence-Based Chunking: Respeitando Limites Naturais
Uma melhoria significativa é dividir em boundaries de sentenças, agrupando sentenças até atingir tamanho-alvo:
import nltk
def sentence_based_chunking(text, target_size=500, max_size=600):
"""
Agrupa sentenças em chunks respeitando boundaries naturais.
Args:
target_size: número-alvo de tokens por chunk
max_size: máximo absoluto antes de forçar split
"""
sentences = nltk.sent_tokenize(text)
chunks = []
current_chunk = []
current_length = 0
for sent in sentences:
sent_length = len(tokenizer.encode(sent))
# Se adicionar sentença excederia max_size, finalizar chunk
if current_length + sent_length > max_size and current_chunk:
chunks.append(" ".join(current_chunk))
current_chunk = []
current_length = 0
current_chunk.append(sent)
current_length += sent_length
# Se atingiu target_size, considerar finalizar chunk
if current_length >= target_size:
chunks.append(" ".join(current_chunk))
current_chunk = []
current_length = 0
# Não esquecer último chunk
if current_chunk:
chunks.append(" ".join(current_chunk))
return chunksSentence-based preserva unidades semânticas completas. Uma vantagem adicional: ao recuperar um chunk relevante, você pode mostrar ao usuário texto fluido, não fragmentos cortados no meio de palavras.
Limitação importante: Sentenças variam enormemente em comprimento. Em texto técnico com listas, uma “sentença” pode ser apenas “NumPy”, resultando em chunks minúsculos e ineficientes. Em prosa acadêmica, uma sentença pode ter 200 tokens, levando a chunks inconsistentes.
Paragraph-Based: Estrutura de Alto Nível
Para documentos bem-estruturados (markdown, HTML, documentação técnica), parágrafos e seções oferecem boundaries semânticos ainda mais fortes:
def paragraph_based_chunking(text, target_size=500):
"""
Chunking baseado em parágrafos (separados por \n\n).
Agrupa parágrafos pequenos, divide grandes.
"""
paragraphs = text.split('\n\n')
chunks = []
current_chunk = []
current_length = 0
for para in paragraphs:
para_length = len(tokenizer.encode(para))
# Parágrafo único muito grande: split em sentenças
if para_length > target_size * 1.5:
# Finalizar chunk atual se não-vazio
if current_chunk:
chunks.append('\n\n'.join(current_chunk))
current_chunk = []
current_length = 0
# Split parágrafo grande em sentenças
chunks.extend(sentence_based_chunking(para, target_size))
continue
# Adicionar parágrafo excederia target_size: finalizar chunk
if current_length + para_length > target_size and current_chunk:
chunks.append('\n\n'.join(current_chunk))
current_chunk = []
current_length = 0
current_chunk.append(para)
current_length += para_length
if current_chunk:
chunks.append('\n\n'.join(current_chunk))
return chunksParagraph-based vai muito bem em documentação técnica onde parágrafos naturalmente encapsulam conceitos completos. No entanto, essa estratégia depende de texto bem-formatado com parágrafos claramente delimitados. Quando aplicada a texto bruto sem estrutura (como transcrições ou dumps de texto corrido), a abordagem paragraph-based degrada e você precisará recorrer a fixed-size ou sentence-based chunking.
Semantic Chunking: Usando Embeddings para Dividir
A técnica mais sofisticada usa os próprios embeddings para identificar onde conceitos mudam, cortando em transitions semânticas:
from sentence_transformers import SentenceTransformer
import numpy as np
def semantic_chunking(text, model, similarity_threshold=0.7, min_chunk_size=3):
"""
Divide texto quando similaridade entre sentenças consecutivas cai abaixo de threshold.
Intuição: sentenças sobre o mesmo conceito têm embeddings similares.
Quando similaridade cai, provável transition para novo tópico.
"""
sentences = nltk.sent_tokenize(text)
embeddings = model.encode(sentences)
chunks = []
current_chunk = [sentences[0]]
for i in range(1, len(sentences)):
# Similaridade entre sentença atual e anterior
similarity = np.dot(embeddings[i], embeddings[i-1])
# Se similaridade alta, ainda no mesmo conceito
if similarity >= similarity_threshold:
current_chunk.append(sentences[i])
else:
# Similaridade baixa = novo conceito
if len(current_chunk) >= min_chunk_size:
chunks.append(" ".join(current_chunk))
current_chunk = [sentences[i]]
else:
# Chunk muito pequeno, forçar continuar
current_chunk.append(sentences[i])
if current_chunk:
chunks.append(" ".join(current_chunk))
return chunksSemantic chunking é computacionalmente mais caro (requer embedding de todas as sentenças), mas pode significativamente melhorar qualidade em textos que discutem múltiplos tópicos não-relacionados. É particularmente eficaz em transcrições de reuniões ou artigos de blog que cobrem várias ideias.
Overlap Strategies: Preservando Contexto Entre Chunks
Independente da estratégia de chunking escolhida, implementar sobreposição (overlap) entre chunks é essencial para garantir que informações que cruzam fronteiras entre chunks não sejam perdidas. Quando um conceito importante começa no final de um chunk e termina no início do próximo, a ausência de overlap resulta em fragmentação semântica que prejudica significativamente a recuperação.
Duas abordagens principais dominam a prática:
Sliding Window: O chunk N+1 inicia X tokens antes do fim do chunk N, criando uma zona de overlap fixo. Por exemplo, com chunks de 500 tokens e overlap de 50 tokens, cada novo chunk compartilha seus primeiros 50 tokens com os últimos 50 do chunk anterior. Essa técnica é simples de implementar e garante continuidade básica, sendo adequada para a maioria dos casos.
Contextual Overlap: Ao invés de duplicação cega de tokens, essa abordagem inclui 1-2 sentenças completas do chunk anterior como contexto explicitamente marcado. Isso é particularmente útil quando um LLM processará o chunk posteriormente, pois o contexto ajuda o modelo a entender melhor o fragmento. A marcação explícita (como [Contexto: ...]) permite que o sistema saiba qual parte é contexto e qual é conteúdo principal.
def add_contextual_overlap(chunks, num_context_sentences=1):
"""Adiciona sentenças finais do chunk anterior como contexto."""
overlapped_chunks = [chunks[0]] # Primeiro chunk sem contexto anterior
for i in range(1, len(chunks)):
prev_sentences = nltk.sent_tokenize(chunks[i-1])
context = " ".join(prev_sentences[-num_context_sentences:])
overlapped_chunks.append(f"[Contexto: {context}]\n\n{chunks[i]}")
return overlapped_chunksA escolha do percentual de overlap envolve um trade-off direto: overlap típico de 10-20% duplica armazenamento e aumenta custos computacionais, mas reduz significativamente o risco de perder informação que cruza boundaries entre chunks. A melhoria em recall depende fortemente do domínio e tipo de conteúdo - textos técnicos com conceitos interconectados se beneficiam mais que listas ou FAQs estruturadas.
Tamanho Ótimo: Uma Decisão Guiada pelo Domínio
A escolha do tamanho ideal de chunk não é universal. Ela depende de três fatores fundamentais: o domínio do conteúdo, a estrutura natural do texto, e o tipo de queries que o sistema precisa responder. Um chunk muito pequeno fragmenta contexto, enquanto um chunk muito grande dilui a relevância. O objetivo é encontrar o equilíbrio onde cada chunk contém informação suficiente para ser semanticamente coerente, mas não tanta que torne difícil identificar qual parte é realmente relevante para uma query específica.
Na prática, diferentes domínios apresentam características estruturais distintas que sugerem abordagens específicas.
Código-fonte beneficia-se de chunks menores (100-200 tokens) alinhados com boundaries naturais de funções ou classes. Código é altamente estruturado, e funções individuais geralmente representam unidades semânticas completas. Paragraph-based chunking que respeita essas estruturas tende a funcionar melhor que divisões arbitrárias.
Documentação técnica requer chunks intermediários (400-600 tokens) que balanceiam seções completas com contexto compreensível. Uma abordagem híbrida de paragraph-based com fallback para sentence-based em seções muito longas preserva tanto a estrutura conceitual quanto a legibilidade.
Artigos científicos funcionam bem com chunks maiores (500-800 tokens) onde parágrafos conceituais naturalmente encapsulam ideias completas. Semantic chunking pode ser particularmente eficaz aqui, detectando transitions entre conceitos através de mudanças nos embeddings de sentenças consecutivas.
FAQs e Q&A são naturalmente concisas, favorecendo chunks menores (200-400 tokens) de 1-3 sentenças que capturam perguntas e respostas completas sem diluição de contexto.
Transcrições e chat logs apresentam desafios únicos pela ausência de estrutura formal. Chunks de 300-500 tokens com semantic chunking ajudam a detectar mudanças de tópico que não são explicitamente marcadas.
Documentação legal demanda chunks maiores (600-1000 tokens) para preservar cláusulas completas, onde contexto legal completo é crítico para interpretação correta.
Essas são diretrizes iniciais baseadas em características estruturais de cada domínio, não prescrições rígidas. Experimentação com seu dataset específico é essencial. O método ideal frequentemente é híbrido: paragraph-based com fallback para sentence-based em parágrafos grandes, mais overlap contextual de 10-20% para preservar informação que cruza boundaries.
Para implementações completas das três principais estratégias de chunking (fixed-size, sentence-based e paragraph-based) com código executável e comparações, veja o Exemplo 2: Estratégias de Chunking.
Vector Databases: Fundamentos
O Problema da Curse of Dimensionality
Buscar os k vetores mais similares em um conjunto de embeddings parece trivial: calcular distância/similaridade entre a query e cada documento, ordenar, retornar os top-k. Essa abordagem “brute-force” funciona perfeitamente para 1.000 documentos. Para 1 milhão? Cada query requer 1 milhão de comparações vetoriais. Inviável em latências de produção (sub-100ms).
O problema se agrava em alta dimensionalidade. Em 768 dimensões, cada comparação é 768 multiplicações e somas. Para 1M documentos × 768 dims = 768 milhões de operações por query. Mesmo com otimizações SIMD, isso leva centenas de milissegundos.
Vector databases resolvem esse problema através de estruturas de indexação especializadas que trocam perfeição por velocidade. Ao invés de verificar todos os vetores, elas exploram propriedades geométricas para eliminar rapidamente regiões irrelevantes do espaço, verificando apenas candidatos prováveis.
Vamos explorar um pouco sobre as principais técnicas e bibliotecas utilizadas para busca vetorial eficiente.
FAISS: O Padrão para Busca Vetorial
FAISS (Facebook AI Similarity Search) (Johnson, Douze, e Jégou 2019), desenvolvida pela Meta AI Research, estabeleceu-se como a biblioteca de referência para busca vetorial em larga escala. Sua força reside na flexibilidade arquitetural: oferece um espectro de algoritmos desde busca exata até aproximações ultra-rápidas, cada um com seus próprios trade-offs entre velocidade, precisão e consumo de memória. Essa variedade permite que você otimize o sistema precisamente para suas necessidades, seja priorizando exatidão absoluta em aplicações críticas ou maximizando throughput em sistemas de recomendação real-time.
A biblioteca organiza-se em torno de estruturas de índices, cada uma implementando uma estratégia diferente de organização e busca no espaço vetorial. Vejamos os principais tipos, do mais simples ao mais sofisticado:
IndexFlatL2/IndexFlatIP são os índices mais utilizados do FAISS, implementando busca exaustiva sem qualquer otimização ou aproximação. O sufixo “Flat” indica que não há estrutura de indexação, apenas uma lista plana de vetores. IndexFlatL2 usa distância euclidiana (L2), enquanto IndexFlatIP usa produto interno (Inner Product), que equivale a similaridade cosseno quando vetores estão normalizados.
Esses índices comparam a query contra literalmente todos os vetores armazenados, resultando em complexidade O(n) onde n é o número de documentos. Apesar de parecerem primitivos, servem dois propósitos críticos: primeiro, como baseline para avaliar qualidade de índices aproximados, já que garantem resultados exatos, você pode medir quantos pontos percentuais perde ao usar aproximações mais rápidas. Segundo, para conjuntos pequenos onde n menor que 10.000 documentos, a busca exaustiva é suficientemente rápida em hardware moderno, sub-10ms em CPU típica, tornando otimizações mais complexas desnecessárias.
import faiss
import numpy as np
# Criar índice flat (exato) com similaridade cosseno (Inner Product)
dimension = 768
index = faiss.IndexFlatIP(dimension)
# Adicionar vetores normalizados (importante para IP = cosine)
embeddings = np.random.random((10000, dimension)).astype('float32')
faiss.normalize_L2(embeddings) # Normalizar para L2 = 1
index.add(embeddings)
# Buscar top-5 mais similares
query = np.random.random((1, dimension)).astype('float32')
faiss.normalize_L2(query)
distances, indices = index.search(query, k=5)IndexIVFFlat (Inverted File with Flat storage) implementa uma estratégia de particionamento do espaço vetorial que reduz drasticamente o número de comparações necessárias durante a busca. A técnica central é dividir o corpus em clusters usando k-means durante uma fase de treinamento offline.
O processo funciona em duas etapas bem definidas. Primeiro, durante a indexação, o algoritmo executa k-means para particionar todos os vetores em nlist clusters, cada cluster representado por um centroide. Quando um novo vetor é adicionado ao índice, ele é atribuído ao cluster cujo centroide está mais próximo, criando uma estrutura de inverted file onde cada cluster mantém uma lista dos vetores que pertencem a ele. Segundo, durante a busca, ao invés de comparar a query contra todos os documentos, o sistema primeiro calcula distâncias apenas para os nlist centroides, identifica os n_probe clusters mais próximos, e então realiza busca exaustiva exclusivamente dentro desses clusters selecionados.
O parâmetro n_probe controla o trade-off fundamental entre velocidade e qualidade. Com n_probe=1, apenas o cluster mais próximo é verificado, maximizando velocidade mas arriscando perder resultados relevantes que estão em clusters vizinhos. Com n_probe=nlist, todos os clusters são verificados, degenerando para busca exaustiva. Na prática, valores típicos são n_probe=10-20 para nlist=100, oferecendo recall de 95-98% com aceleração de 5-10x comparado a IndexFlat.
A escolha de nlist também é crítica e geralmente segue a heurística nlist = sqrt(n) onde n é o número de vetores. Para 1 milhão de documentos, nlist ≈ 1000 é um bom ponto de partida. Clusters muito grandes (nlist baixo) reduzem a eficácia do particionamento, enquanto clusters muito pequenos (nlist alto) aumentam o overhead de manter e buscar centroides.
# IVF com 100 clusters
nlist = 100 # Número de clusters
quantizer = faiss.IndexFlatIP(dimension)
index = faiss.IndexIVFFlat(quantizer, dimension, nlist)
# Treinar requer dados representativos
index.train(embeddings)
index.add(embeddings)
# Buscar checando 10 clusters (de 100 total)
index.nprobe = 10
distances, indices = index.search(query, k=5)Para 1M documentos com 100 clusters e nprobe=10, verificamos ~10K vetores ao invés de 1M - aceleração de 100x, com recall típico de 95-98%.
IndexHNSW (Hierarchical Navigable Small World) (Malkov e Yashunin 2018) representa o estado da arte em busca vetorial aproximada, oferecendo um equilíbrio excepcional entre qualidade e velocidade. A estrutura baseia-se em grafos navegáveis inspirados no fenômeno “small world” das redes sociais, onde qualquer pessoa está conectada a qualquer outra através de poucos intermediários.
A arquitetura HNSW constrói múltiplas camadas hierárquicas de grafos, cada uma contendo diferentes densidades de conexões entre vetores. A camada inferior (layer 0) contém todos os vetores, cada um conectado a seus M vizinhos mais próximos, formando uma rede densa e precisa. Camadas superiores são progressivamente esparsas, contendo apenas subconjuntos de vetores com conexões de longo alcance que servem como “atalhos” para navegação rápida no espaço vetorial.
O processo de busca é elegante e eficiente. Iniciando no topo da hierarquia (camada mais esparsa), o algoritmo usa greedy search para navegar através das conexões de longo alcance até identificar a região aproximada onde os nearest neighbors devem estar. Descendo para camadas inferiores progressivamente mais densas, a busca refina-se iterativamente até atingir a layer 0, onde realiza a busca final precisa entre os candidatos mais promissores. Esse processo multi-escala permite que HNSW explore eficientemente espaços de alta dimensão sem examinar a maioria dos vetores.
Os parâmetros críticos de HNSW são M (número de conexões bidirecionais por nó) e ef_construction (tamanho da lista dinâmica durante construção). Valores típicos são M=16-64 e ef_construction=100-200. M maior aumenta recall e velocidade de busca, mas consome mais memória (cada conexão adicional armazena um ponteiro). ef_construction controla o trade-off entre qualidade do grafo construído e tempo de indexação, valores maiores produzem grafos de melhor qualidade mas levam mais tempo para construir.
HNSW oferece recall excepcional, rotineiramente superior a 99% em benchmarks, com latências sub-milissegundo mesmo para milhões de vetores. No entanto, esses benefícios vêm com custos significativos. O índice ocupa aproximadamente 2-3x mais memória que os vetores originais devido ao overhead das estruturas de grafo. A construção do índice é computacionalmente intensiva, levando cerca de 1 hora para 100 milhões de vetores em hardware típico. Adicionalmente, HNSW não suporta remoção eficiente de vetores, apenas adições, o que pode ser limitante em aplicações com alta rotatividade de documentos.
Para produção com requisitos de alta throughput (QPS - queries per second), onde latências consistentemente baixas são críticas e memória não é o gargalo principal, HNSW é frequentemente a escolha ideal. É particularmente adequado para sistemas de recomendação real-time, busca de imagens em larga escala, e aplicações que demandam a melhor qualidade possível de busca aproximada.
import faiss
import numpy as np
# Parâmetros HNSW
dimension = 768
M = 32 # Número de conexões bidirecionais por nó
ef_construction = 200 # Tamanho da lista durante construção
# Criar índice HNSW
index = faiss.IndexHNSWFlat(dimension, M)
index.hnsw.efConstruction = ef_construction
# Adicionar vetores (pode levar tempo para datasets grandes)
embeddings = np.random.random((100000, dimension)).astype('float32')
faiss.normalize_L2(embeddings)
index.add(embeddings)
# Configurar ef_search para busca (maior = mais recall, mais lento)
index.hnsw.efSearch = 50 # Típico: 16-512
# Buscar
query = np.random.random((1, dimension)).astype('float32')
faiss.normalize_L2(query)
distances, indices = index.search(query, k=10)
print(f"Top-10 vizinhos mais próximos: {indices[0]}")
print(f"Distâncias: {distances[0]}")
# Salvar índice para uso posterior (importante: HNSW demora para construir)
faiss.write_index(index, "hnsw_index.faiss")
# Carregar índice salvo
index_loaded = faiss.read_index("hnsw_index.faiss")Para balancear recall vs velocidade, ajuste ef_search dinamicamente: use valores baixos (16-32) para aplicações latency-critical, médios (50-100) para uso geral, e altos (100-512) quando recall máximo é essencial. Durante construção, prefira ef_construction alto (200-400) uma vez, pois o grafo resultante será usado por muito tempo. O parâmetro M é melhor definido durante criação do índice e raramente precisa ajuste: M=16 para datasets <1M vetores, M=32 para 1M-10M, e M=64 para >10M vetores.
Pinecone, Weaviate, Chroma, Qdrant: Comparação Estratégica
FAISS é uma biblioteca, não um banco de dados completo, não oferece persistência, sharding distribuído, ou APIs RESTful. Vector databases gerenciadas preenchem essa lacuna:
Pinecone (Managed Cloud): Foco em simplicidade e escala. Abstrai completamente detalhes de indexação - você apenas faz upload de vetores e consulta. Escala automaticamente, suporta metadata filtering, e oferece latências consistentes. Trade-off: sem self-hosting, custos podem ser significativos ($0.08/hora para 100K vetores, $0.40 para 1M). Ideal para startups que priorizam time-to-market sobre controle.
Weaviate (Open-Source + Managed): Arquitetura mais complexa com suporte a schema, GraphQL, e módulos de ML integrados. Permite hybrid search (vector + keyword) nativamente. Self-hostable para controle total, ou managed para conveniência. Excelente para aplicações que precisam combinar busca estruturada e semântica.
Chroma (Open-Source, Embedded): Projetado para embeddings de LLM applications. Extremamente fácil de usar - literalmente 3 linhas de código para começar. Roda in-process (como SQLite) ou como servidor. Limitado a datasets menores (~1-10M vetores), mas perfeito para protótipos e aplicações médias. Comunidade ativa no ecossistema LangChain/LlamaIndex.
Qdrant (Open-Source + Managed): Balance entre performance e features. Escrito em Rust para máxima eficiência, suporta filtering complexo, payloads JSON arbitrários, e sharding distribuído. API RESTful e gRPC. Comparável a Weaviate em funcionalidade, mas frequentemente mais rápido. Ótima escolha para self-hosting em produção.
| Característica | FAISS | Pinecone | Weaviate | Chroma | Qdrant |
|---|---|---|---|---|---|
| Self-hosting | Sim (lib) | Não | Sim | Sim | Sim |
| Managed option | Não | Sim | Sim | Não | Sim |
| Escala (vetores) | Bilhões | Milhões | Milhões | ~10M | Milhões |
| Metadata filtering | Manual | Sim | Sim | Básico | Sim |
| Hybrid search | Manual | Limitado | Nativo | Não | Sim |
| Latência p99 (1M) | <1ms | <10ms | <5ms | <20ms | <3ms |
| Curva de aprendizado | Alta | Baixa | Média | Muito baixa | Média |
Decisão estratégica:
- Protótipo/MVP: Chroma (simplicidade) ou FAISS (controle total)
- Produção self-hosted: Qdrant ou Weaviate (features completas)
- Produção managed sem DevOps: Pinecone (abstração total)
- Máxima escala (100M+): FAISS com infraestrutura custom
Quantização e Otimização de Armazenamento
Embeddings de 768 dimensões em float32 ocupam 3KB por vetor. Para 10 milhões de documentos, isso soma 30GB apenas para os vetores, sem contar índices e metadata. Quantização comprime embeddings reduzindo precisão numérica, trocando qualidade mínima por economia dramática de espaço e ganhos em velocidade de busca.
Quantização int8 reduz cada dimensão de 32 bits (float32) para 8 bits (int8), comprimindo embeddings 4x. A conversão é direta: normalize o range de valores para [-128, 127] através de min-max scaling. O impacto em qualidade é surpreendentemente pequeno - tipicamente <2% de degradação em Precision@10, enquanto o índice ocupa 1/4 do espaço e busca pode ser até 2x mais rápida devido a melhor cache locality.
import numpy as np
def quantize_to_int8(embeddings):
"""
Quantiza embeddings float32 para int8.
Args:
embeddings: array numpy shape (n_vectors, dim) em float32
Returns:
quantized: array int8
scale: fator de escala para dequantização
zero_point: offset para dequantização
"""
# Calcular min/max por dimensão
min_vals = embeddings.min(axis=0)
max_vals = embeddings.max(axis=0)
# Escalar para range [-128, 127]
scale = (max_vals - min_vals) / 255.0
zero_point = min_vals
quantized = ((embeddings - zero_point) / scale - 128).astype(np.int8)
return quantized, scale, zero_point
def dequantize_from_int8(quantized, scale, zero_point):
"""Reconverte int8 para float32 aproximado."""
return (quantized.astype(np.float32) + 128) * scale + zero_point
# Demonstração
embeddings = np.random.randn(1000, 768).astype(np.float32)
quantized, scale, zero_point = quantize_to_int8(embeddings)
print(f"Original: {embeddings.nbytes / 1024:.2f} KB")
print(f"Quantizado: {quantized.nbytes / 1024:.2f} KB")
print(f"Compressão: {embeddings.nbytes / quantized.nbytes:.1f}x")Product Quantization (PQ) (Jegou, Douze, e Schmid 2011) vai além, alcançando compressão de 32-64x com degradação controlada. A técnica divide cada vetor em m subvetores (ex: 768 dims → 96 subvetores de 8 dims), depois quantiza cada subvetor independentemente usando k-means para criar um codebook de 256 centroides. Cada subvetor é então representado por um único byte indicando seu centroide mais próximo. Para 768 dimensões com 96 subvetores, isso reduz de 3KB para apenas 96 bytes por vetor - compressão de 32x.
FAISS implementa PQ através de IndexIVFPQ, combinando inverted file structure com product quantization:
import faiss
dimension = 768
nlist = 100 # Clusters IVF
m = 96 # Número de subvetores (dimension deve ser divisível por m)
n_bits = 8 # Bits por subvetor (2^8 = 256 centroides)
# Criar índice IVF+PQ
quantizer = faiss.IndexFlatL2(dimension)
index = faiss.IndexIVFPQ(quantizer, dimension, nlist, m, n_bits)
# Treinar com dados representativos
index.train(training_embeddings)
index.add(embeddings)
# Busca com PQ é ~10-20x mais rápida que flat
index.nprobe = 10
distances, indices = index.search(query, k=10)O trade-off de PQ é mais agressivo: expect 5-10% de degradação em Precision@10 comparado a vetores completos. Para mitigar, use refinement: após busca inicial com PQ retornando top-100, recompute distâncias exatas apenas desses 100 usando vetores originais armazenados separadamente. Isso captura 98-99% da qualidade de busca exata mantendo 90% do ganho de velocidade.
Binary embeddings representam o extremo: cada dimensão vira um único bit (0 ou 1), alcançando compressão de 32x. Surpreendentemente, binary hashing bem projetado mantém 85-90% da qualidade em muitas tarefas, sendo a escolha ideal para sistemas com constraints de memória extremos, como busca em dispositivos mobile ou edge computing. A técnica mais simples é sign-based: binary[i] = 1 if embedding[i] > 0 else 0. Distâncias são então calculadas via Hamming distance, extremamente eficiente em hardware (operação XOR + popcount).
Quando usar cada técnica? Int8 quantização é o padrão para produção. Tem ganho significativo com custo mínimo em qualidade e zero mudanças arquiteturais. Product Quantization justifica-se quando armazenamento é o gargalo principal e você pode tolerar 5-10% de degradação, típico em sistemas com 50M+ vetores onde o índice não cabe em RAM. Binary embeddings são para casos extremos onde constraints de memória ou latência dominam requisitos de qualidade, como aplicações mobile ou sistemas embarcados. Em todos os casos, sempre valide impact em seu test set específico, pois o trade-off qualidade/compressão varia significativamente por domínio e distribuição de dados.
Semantic Search na Prática
Até aqui exploramos os componentes fundamentais de forma isolada: como embeddings capturam significado semântico, estratégias de chunking para documentos longos, estruturas de indexação em vector databases, e métricas de similaridade. Agora é hora de integrar essas peças em um sistema funcional completo.
Semantic search é a aplicação prática mais importante de embeddings em sistemas de agentes. É a tecnologia que permite que agentes encontrem informação relevante em vastas coleções de documentos, possibilitando arquiteturas como RAG (Retrieval-Augmented Generation) que exploraremos na Parte II. Dominar a construção de pipelines de semantic search é essencial antes de avançarmos para sistemas multi-agentes complexos.
A construção de um sistema de semantic search robusto requer mais que apenas embeddings e um vector database isolados. É necessário um pipeline completo que integra chunking inteligente, indexação eficiente, retrieval preciso, e potencialmente re-ranking para máxima qualidade. Vejamos como orquestrar esses componentes em uma arquitetura end-to-end que serve como fundação para os sistemas de agentes que construiremos nos próximos capítulos.
Pipeline Completo: Da Ingestão ao Resultado
Um sistema de semantic search production-ready opera em duas fases distintas: indexação offline e busca online. A fase de indexação é executada uma vez (ou periodicamente quando documentos são atualizados) e prepara todo o corpus para buscas rápidas. Ela envolve carregar documentos brutos, aplicar estratégias de chunking para segmentar textos longos, gerar embeddings densos para cada chunk usando um modelo de embedding, e finalmente construir um índice vetorial otimizado (como HNSW) que possibilita buscas sub-lineares.
A fase de busca, executada em tempo real para cada query do usuário, é onde a magia acontece. O sistema transforma a query natural do usuário no mesmo espaço vetorial dos documentos através do modelo de embedding, navega eficientemente pelo índice FAISS para encontrar os k chunks mais semanticamente similares, e retorna esses resultados ranqueados por relevância, frequentemente acompanhados de metadata útil para rastreabilidade.
A classe SemanticSearchEngine abaixo encapsula esse pipeline completo em uma interface limpa e reutilizável. Note como ela mantém estado persistente (o índice FAISS, os chunks processados, e metadata associada), permitindo que múltiplas queries sejam executadas sem reprocessar documentos. Essa separação clara entre indexação e busca é fundamental para escalabilidade: você pode indexar milhões de documentos uma vez offline, e então servir milhares de queries por segundo com latências de milissegundos.
class SemanticSearchEngine:
"""Pipeline completo de semantic search."""
def __init__(self, model_name='all-mpnet-base-v2'):
self.model = SentenceTransformer(model_name)
self.dimension = self.model.get_sentence_embedding_dimension()
self.index = None
self.chunks = []
self.metadata = []
def index_documents(self, documents, chunk_strategy='paragraph'):
"""
Fase 1: Ingestão e Indexação
1. Chunkar documentos
2. Gerar embeddings
3. Construir índice FAISS
"""
for doc_id, doc in enumerate(documents):
chunks = self.chunk_document(doc, strategy=chunk_strategy)
for chunk_id, chunk in enumerate(chunks):
self.chunks.append(chunk)
self.metadata.append({
'doc_id': doc_id,
'chunk_id': chunk_id,
'source': doc.get('source', 'unknown')
})
# Gerar embeddings em batch para eficiência
embeddings = self.model.encode(
self.chunks,
batch_size=32,
show_progress_bar=True,
normalize_embeddings=True
)
# Construir índice HNSW para baixa latência
self.index = faiss.IndexHNSWFlat(self.dimension, 32)
self.index.add(embeddings.astype('float32'))
def search(self, query, top_k=5):
"""
Fase 2: Retrieval
1. Embedding da query
2. Busca aproximada no índice
3. Retornar chunks mais relevantes
"""
query_embedding = self.model.encode(
[query],
normalize_embeddings=True
).astype('float32')
distances, indices = self.index.search(query_embedding, top_k)
results = []
for dist, idx in zip(distances[0], indices[0]):
results.append({
'chunk': self.chunks[idx],
'score': float(dist), # Similaridade cosseno
'metadata': self.metadata[idx]
})
return resultsPara a implementação completa do pipeline de semantic search com chunking, indexação HNSW e retrieval, veja o Exemplo 3: Semantic Search Engine com FAISS.
Hybrid Search: Combinando Vetores e Keywords
Semantic search através de embeddings revolucionou a recuperação de informação ao capturar significado e contexto, mas essa força revela uma vulnerabilidade crítica: a perda de precisão lexical. Quando um usuário busca pelo número exato de um modelo (“GPT-4”), um identificador técnico específico (“HNSW”), ou um termo raro mas crucial (“mitocôndria”), embeddings podem falhar surpreendentemente. O modelo pode entender o conceito geral mas não dar peso suficiente ao match exato da string, retornando documentos semanticamente relacionados mas lexicalmente incorretos.
Essa limitação não é um defeito dos embeddings, mas uma consequência natural de como funcionam. Ao comprimir texto em vetores densos de dimensão fixa, informação sobre tokens específicos é parcialmente perdida em favor de capturar padrões semânticos gerais. Um documento sobre “GPT-3.5” terá embedding muito similar a um sobre “GPT-4” porque ambos discutem modelos de linguagem da OpenAI, mas para muitas queries essa distinção sutil é exatamente o que importa.
Hybrid search resolve esse dilema fundamental combinando o melhor de dois mundos: busca semântica através de embeddings para capturar significado e contexto, e busca léxica através de algoritmos como BM25 para garantir precisão em matches exatos de termos específicos. A técnica orquestra ambas as abordagens em paralelo, mesclando seus resultados através de uma combinação ponderada de scores que permite ajustar dinamicamente o balanço entre compreensão semântica e precisão lexical dependendo das necessidades da aplicação.
A busca léxica utiliza BM25 (Best Match 25) (Robertson e Zaragoza 2009), uma função de ranking probabilística que se tornou o padrão da indústria para retrieval baseado em keywords. BM25 pontua documentos considerando dois fatores fundamentais: term frequency (TF), quantas vezes um termo da query aparece no documento, com saturação para evitar que repetições excessivas dominem o score; e inverse document frequency (IDF), quão raro é o termo no corpus inteiro, dando mais peso a termos distintivos que aparecem em poucos documentos.
from rank_bm25 import BM25Okapi
import numpy as np
class HybridSearchEngine(SemanticSearchEngine):
"""Combina semantic search (embeddings) com keyword search (BM25)."""
def index_documents(self, documents, chunk_strategy='paragraph'):
# Indexação vetorial (herda da classe pai)
super().index_documents(documents, chunk_strategy)
# Indexação BM25
tokenized_chunks = [chunk.lower().split() for chunk in self.chunks]
self.bm25 = BM25Okapi(tokenized_chunks)
def hybrid_search(self, query, top_k=10, alpha=0.5):
"""
alpha controla o peso: 1.0 = só semantic, 0.0 = só BM25
"""
# Busca semântica (retorna similaridade cosseno [0, 1])
semantic_results = self.search(query, top_k=top_k * 2)
# Busca BM25 (retorna scores não-normalizados)
bm25_scores = self.bm25.get_scores(query.lower().split())
# Normalizar BM25 scores para [0, 1]
if max(bm25_scores) > 0:
bm25_scores = bm25_scores / max(bm25_scores)
# Combinar scores
combined_scores = {}
for result in semantic_results:
idx = self.chunks.index(result['chunk'])
semantic_score = result['score']
bm25_score = bm25_scores[idx]
combined_scores[idx] = alpha * semantic_score + (1 - alpha) * bm25_score
# Ordenar por score combinado
ranked_indices = sorted(combined_scores.items(),
key=lambda x: x[1],
reverse=True)[:top_k]
return [{'chunk': self.chunks[idx],
'score': score,
'metadata': self.metadata[idx]}
for idx, score in ranked_indices]Para a implementação completa do sistema de hybrid search combinando embeddings e BM25, veja o Exemplo 4: Hybrid Search (Semantic + BM25).
Em benchmarks práticos, hybrid search com alpha=0.7 (70% semantic, 30% BM25) tipicamente supera busca puramente semântica em 10-15 pontos percentuais em precision@5.
Avaliação: Métricas Que Importam
Paralelo com Avaliação de LLMs
As métricas de avaliação de sistemas de busca compartilham princípios com as métricas de LLMs discutidas no Capítulo 3. Assim como Precision e Recall avaliam qualidade de classificação em modelos, aqui avaliam qualidade de retrieval. NDCG é análogo a métricas de ranking em generation tasks, considerando não apenas se a resposta é correta, mas quão bem ranqueada está. A metodologia de usar test sets separados com ground truth, evitar data leakage, e reportar múltiplas métricas complementares é idêntica. A diferença principal é o domínio: lá avaliávamos geração de texto, aqui avaliamos recuperação de documentos, mas os fundamentos de rigor científico permanecem os mesmos.
Vamos explorar as principais métricas usadas para avaliar sistemas de semantic search:
Precision@K: Dos K documentos retornados, quantos são relevantes? Precision@5 = 4/5 = 0.8 se 4 dos top-5 são relevantes. Crítico quando usuário vê apenas primeiros resultados.
Recall@K: Dos documentos relevantes totais, quantos foram capturados nos top-K? Se existem 10 documentos relevantes e retornamos 6 deles nos top-20, Recall@20 = 6/10 = 0.6. Importante quando precisamos garantir não perder informação crítica.
MRR (Mean Reciprocal Rank): Mede quão cedo o primeiro resultado relevante aparece. Se primeira resposta relevante está na posição 3, RR = 1/3 = 0.33. Média através de múltiplas queries. Útil para Q&A onde usuário espera resposta imediata.
NDCG (Normalized Discounted Cumulative Gain): Considera tanto relevância quanto posição, com desconto logarítmico para posições mais baixas. Permite relevância gradual (muito relevante vs. parcialmente relevante). Gold standard para avaliação de ranking (Järvelin e Kekäläinen 2002).
from sklearn.metrics import ndcg_score
import numpy as np
def evaluate_search_system(search_engine, test_queries, ground_truth):
"""
Avalia sistema de busca em test set com ground truth.
Args:
search_engine: Engine com método search(query, top_k)
test_queries: lista de queries de teste
ground_truth: dict {query_id: [list of relevant doc_ids]}
Returns:
dict com Precision@5, Recall@10, NDCG agregados
"""
all_ndcg = []
all_precision_at_5 = []
all_recall_at_10 = []
for query_id, query in enumerate(test_queries):
results = search_engine.search(query, top_k=10)
retrieved_doc_ids = [r['metadata']['doc_id'] for r in results]
relevant_doc_ids = ground_truth[query_id]
# Precision@5
relevant_in_top5 = len(set(retrieved_doc_ids[:5]) & set(relevant_doc_ids))
all_precision_at_5.append(relevant_in_top5 / 5.0)
# Recall@10
relevant_in_top10 = len(set(retrieved_doc_ids[:10]) & set(relevant_doc_ids))
all_recall_at_10.append(relevant_in_top10 / len(relevant_doc_ids) if relevant_doc_ids else 0)
# NDCG - usar scores reais de similaridade
true_relevance = [1 if doc_id in relevant_doc_ids else 0
for doc_id in retrieved_doc_ids]
# Usar scores de similaridade dos resultados como predicted scores
predicted_scores = [r['score'] for r in results]
if sum(true_relevance) > 0: # Apenas se há documentos relevantes
ndcg = ndcg_score([true_relevance], [predicted_scores])
all_ndcg.append(ndcg)
return {
'p@5': np.mean(all_precision_at_5),
'r@10': np.mean(all_recall_at_10),
'ndcg': np.mean(all_ndcg) if all_ndcg else 0.0
}Para sistemas de produção, meta mínima é Precision@5 > 0.7 e NDCG > 0.6. Sistemas de alta qualidade atingem Precision@5 > 0.85.
Fine-tuning de Embeddings
Modelos de embedding pré-treinados como Sentence Transformers são surpreendentemente eficazes em domínios gerais, mas podem falhar em terminologia highly-specialized. Documentação médica, jurídica, ou técnicas de nicho frequentemente contêm jargão e relações semânticas que modelos gerais nunca viram durante treinamento. Fine-tuning adapta embeddings para seu domínio específico.
Quando Vale a Pena Fine-Tunar?
Fine-tuning requer esforço significativo: coleta de dados, treinamento, validação e manutenção contínua de modelo customizado. A decisão deve ser estratégica, baseada em trade-offs claros entre custo e benefício. Não é uma otimização que se aplica uniformemente a todos os casos, mas uma ferramenta poderosa quando as condições certas se alinham.
Vale a pena investir em fine-tuning quando:
Seu domínio é altamente especializado com vocabulário único não bem representado em modelos gerais. Documentação médica com termos anatômicos precisos, contratos legais com linguagem jurídica específica, ou literatura científica com nomenclatura técnica de nicho são candidatos ideais. A performance de embeddings gerais está abaixo de requisitos mínimos, tipicamente Precision@5 menor que 0.65, indicando que o modelo base não captura adequadamente as nuances semânticas do seu domínio. Você possui ou pode gerar pelo menos 10K exemplos de pares query-documento relevante, quantidade mínima para treinar sem overfitting severo. E finalmente, uma melhoria esperada de 10-15% em métricas de qualidade justifica economicamente o custo de desenvolvimento, infraestrutura de treinamento, e manutenção contínua do modelo customizado.
Fine-tuning provavelmente não se justifica quando:
Você opera em domínios gerais como notícias, e-commerce ou suporte ao cliente, onde modelos pré-treinados como all-mpnet-base-v2 já capturam bem o vocabulário e relações semânticas, tendo sido expostos a textos similares durante pré-treinamento. Seu dataset tem menos de 5K exemplos, tornando overfitting praticamente inevitável e resultando em modelo que memoriza training data mas não generaliza. Embeddings gerais já atingem performance satisfatória acima de 0.75 em Precision@5, deixando pouco headroom para melhoria que justifique o esforço. Ou quando recursos de engenharia são limitados e seria mais efetivo focar inicialmente em otimizações arquiteturais como melhorar estratégias de chunking, implementar hybrid search, ou ajustar parâmetros de retrieval antes de investir em customização de modelos.
Contrastive Learning: A Base do Fine-Tuning
A técnica dominante para fine-tuning de embeddings é contrastive learning (Chen et al. 2020), uma abordagem elegante que resolve o problema fundamental de adaptar embeddings: como ensinar o modelo quais textos devem estar próximos e quais devem estar distantes no espaço vetorial? Ao invés de treinar para prever tokens individuais como em language modeling tradicional, contrastive learning otimiza diretamente a geometria do espaço de embeddings, aproximando representações de pares positivos (semanticamente similares, como query e documento relevante) e afastando pares negativos (dissimilares, como query e documento irrelevante).
Essa abordagem é particularmente poderosa para embeddings porque alinha perfeitamente com a tarefa downstream de retrieval: queremos que embeddings de textos semanticamente relacionados tenham alta similaridade cosseno, e textos não-relacionados tenham baixa similaridade. Ao treinar com contrastive learning, o modelo aprende exatamente essa estrutura.
Triplet Loss: A Função Clássica
Triplet Loss é a loss function fundacional do contrastive learning, operando sobre triplets de exemplos (anchor, positive, negative). O anchor é tipicamente uma query do usuário, positive é um documento relevante para essa query, e negative é um documento irrelevante. A loss penaliza configurações onde a distância anchor→positive não é suficientemente menor que anchor→negative:
L = max(0, margin + d(anchor, positive) - d(anchor, negative))
A intuição geométrica é clara: queremos que a distância d(anchor, positive) seja menor que d(anchor, negative) por pelo menos uma margem margin (tipicamente 0.5-1.0). Se essa condição já é satisfeita, a loss é zero e o gradiente não atualiza os pesos, pois o modelo já aprendeu a distinguir esse triplet. Caso contrário, a loss cresce proporcionalmente à magnitude da violação, empurrando o positive para mais perto do anchor e o negative para mais longe.
Por exemplo, considere anchor = “como instalar bibliotecas Python?”, positive = “documentação pip install”, negative = “receita de bolo de chocolate”. Inicialmente, o modelo pode ter embeddings ruins onde negative está tão próximo quanto positive. Triplet loss corrige isso através de múltiplas iterações de gradiente.
from sentence_transformers import SentenceTransformer, losses, InputExample
from torch.utils.data import DataLoader
# Criar training examples no formato triplet
# Cada InputExample contém [anchor, positive, negative]
train_examples = [
InputExample(texts=[
'como instalar bibliotecas Python?', # anchor
'Use pip install <pacote> para instalar bibliotecas Python', # positive
'Receita de bolo de chocolate com cobertura' # negative
]),
InputExample(texts=[
'qual a sintaxe de list comprehension?',
'List comprehension: [x for x in iterable if condition]',
'Manual de instruções de fogão industrial'
]),
# ... milhares mais
]
model = SentenceTransformer('all-MiniLM-L6-v2')
# Triplet loss com margin=1.0 e distância cosseno
train_dataloader = DataLoader(train_examples, shuffle=True, batch_size=16)
train_loss = losses.TripletLoss(
model=model,
distance_metric=losses.TripletDistanceMetric.COSINE,
triplet_margin=1.0
)
# Fine-tune por 3 épocas
model.fit(
train_objectives=[(train_dataloader, train_loss)],
epochs=3,
warmup_steps=100,
optimizer_params={'lr': 2e-5}
)Desafios do Triplet Loss: A principal limitação é a necessidade de construir triplets manualmente. Para cada anchor, você precisa identificar explicitamente um positive e um negative, o que é trabalhoso e pode introduzir viés na seleção de negatives. Negatives muito fáceis (semanticamente óbvios) não ensinam muito, enquanto negatives muito difíceis (hard negatives, semanticamente próximos mas tecnicamente incorretos) são cruciais mas difíceis de minerar.
InfoNCE Loss: Eficiência com In-Batch Negatives
InfoNCE Loss (Oord, Li, e Vinyals 2018), também chamada de Contrastive Loss ou NT-Xent (Normalized Temperature-scaled Cross Entropy), representa a evolução moderna do contrastive learning e resolve elegantemente o problema de construção de negatives. A ideia central é usar outros exemplos do próprio batch como negatives automáticos, eliminando a necessidade de selecionar negatives manualmente.
Para um batch de tamanho B contendo pares (query, documento_relevante), InfoNCE trata cada query como anchor, seu documento pareado como único positive, e os outros B-1 documentos no batch como negatives. Matematicamente, para query q_i e seu documento positive d_i+:
L = -log(exp(sim(q_i, d_i+) / τ) / Σ_j exp(sim(q_i, d_j) / τ))
Onde τ (tau) é um parâmetro de temperatura que controla quão “suave” é a distribuição (valores típicos: 0.05-0.1), e a soma no denominador percorre todos os B documentos do batch.
A biblioteca Sentence Transformers implementa isso através de MultipleNegativesRankingLoss:
# Mais eficaz e escalável: MultipleNegativesRankingLoss (implementa InfoNCE)
train_examples_pairs = [
InputExample(texts=['como instalar bibliotecas Python?',
'Use pip install <pacote> para instalar bibliotecas']),
InputExample(texts=['qual a sintaxe de list comprehension?',
'List comprehension: [x for x in iterable if condition]']),
# ... apenas pares (query, documento), sem negatives explícitos
]
train_dataloader = DataLoader(train_examples_pairs, shuffle=True, batch_size=64)
train_loss = losses.MultipleNegativesRankingLoss(model=model)
model.fit(
train_objectives=[(train_dataloader, train_loss)],
epochs=3,
warmup_steps=100
)Vantagens dramáticas: Com batch size de 64, cada query vê automaticamente 63 negatives (os outros 63 documentos no batch), sem esforço manual de construção de triplets. Isso resulta em sinal de treinamento muito mais rico que Triplet Loss com apenas um negative. Adicionalmente, a dinâmica dos in-batch negatives cria um currículo de aprendizado natural: à medida que o modelo melhora, os negatives se tornam progressivamente mais difíceis (hard negatives), pois são outros documentos relevantes do dataset, não aleatórios.
Trade-off: InfoNCE requer batches maiores para eficácia máxima (32-128 exemplos), consumindo mais memória GPU. Batch size pequeno (<16) resulta em poucos negatives e sinal de treinamento fraco. Para datasets que cabem em memória, aumente batch size tanto quanto possível; para datasets massivos, considere técnicas de hard negative mining que selecionam os top-K negatives mais difíceis ao invés de usar todos do batch.
Few-Shot Fine-Tuning com Dados Sintéticos
O maior obstáculo ao fine-tuning de embeddings é prático: coleta de dados de treinamento de qualidade. Você precisa de milhares de exemplos rotulados de pares (query, documento) ou triplets (anchor, positive, negative), onde a relevância foi validada manualmente ou através de sinais comportamentais reais. Empresas com produtos estabelecidos podem extrair esses dados de logs de busca e cliques de usuários, mas startups, pesquisadores, ou equipes trabalhando em domínios novos enfrentam um dilema: como obter 10K+ exemplos de treinamento quando não há histórico de uso real? Contratar anotadores humanos para criar pares manualmente é caro (centenas de dólares por hora de trabalho especializado), lento (semanas ou meses para datasets grandes), e sujeito a viés e inconsistência entre anotadores.
Synthetic data generation com LLMs resolve esse obstáculo de forma elegante e economicamente viável. A técnica central é inverter o problema: ao invés de ter queries e buscar documentos relevantes, você parte dos documentos que já possui e usa um LLM forte (GPT-4, Claude) para gerar queries sintéticas que usuários fariam para encontrar cada documento. Isso transforma a tarefa cara de “dado documento, encontre queries relevantes” em uma tarefa automática de geração de texto que LLMs executam excepcionalmente bem:
def generate_synthetic_queries(document, num_queries=5):
"""Usa LLM para gerar queries que o documento responderia."""
prompt = f"""
Documento: {document}
Gere {num_queries} perguntas ou buscas diferentes que um usuário faria
para encontrar este documento. Varie o nível de especificidade.
"""
response = llm.generate(prompt, temperature=0.7)
queries = parse_queries(response) # Parse lista de queries
return [(query, document) for query in queries]Com 1000 documentos gerando 5 queries cada = 5000 pares de treino sintéticos. Estudos mostram que fine-tuning com synthetic data atinge 80-90% da performance de dados reais (Dai et al. 2023), a fração do custo.
Avaliação: Medir Melhoria é Essencial
Após investir tempo e recursos em fine-tuning, validar a melhoria real em um held-out test set é absolutamente crítico antes de qualquer deploy em produção. Essa etapa não é opcional ou meramente burocrática, é a única forma confiável de responder três questões fundamentais: o fine-tuning realmente melhorou o modelo? A melhoria é suficiente para justificar a complexidade operacional adicional de manter um modelo customizado? O modelo generalizou além dos exemplos de treinamento ou apenas memorizou padrões específicos?
Um erro comum é avaliar apenas em exemplos similares aos de treinamento, criando uma falsa sensação de sucesso. O test set deve ser verdadeiramente independente, coletado de fontes diferentes ou em períodos diferentes, representando queries reais que usuários farão em produção. Idealmente, deve conter pelo menos 100-200 queries com relevância manualmente validada por especialistas do domínio, não apenas pelo time técnico.
A metodologia de avaliação deve comparar três configurações sistematicamente: o modelo baseline pré-treinado sem adaptação (para estabelecer performance de partida), o modelo após fine-tuning (para medir ganho absoluto), e opcionalmente configurações intermediárias como apenas aumentar batch size ou ajustar learning rate (para isolar o efeito do fine-tuning de outros fatores). Reporte múltiplas métricas complementares, Precision@5 e NDCG no mínimo, pois cada uma captura aspectos diferentes da qualidade de retrieval:
def compare_models(baseline_model, finetuned_model, test_queries, ground_truth):
"""
Compara baseline vs fine-tuned em métricas chave.
Args:
baseline_model: SentenceTransformer pré-treinado original
finetuned_model: SentenceTransformer após fine-tuning
test_queries: lista de strings com queries do test set
ground_truth: dict {query_id: [list of relevant doc_ids]}
Returns:
dict com métricas comparativas e análise de melhoria
"""
from sentence_transformers import SentenceTransformer
import faiss
import numpy as np
print("=== Avaliação Comparativa de Modelos ===\n")
def evaluate_model(model, queries, docs_corpus, ground_truth):
"""Helper para avaliar um modelo."""
# Gerar embeddings
doc_embeddings = model.encode(docs_corpus, normalize_embeddings=True)
# Criar índice FAISS
index = faiss.IndexFlatIP(doc_embeddings.shape[1])
index.add(doc_embeddings.astype('float32'))
all_p5 = []
all_ndcg = []
all_r10 = []
for query_id, query in enumerate(queries):
# Buscar
query_emb = model.encode([query], normalize_embeddings=True)
distances, indices = index.search(query_emb.astype('float32'), k=10)
retrieved_ids = indices[0].tolist()
relevant_ids = ground_truth[query_id]
# Precision@5
relevant_in_top5 = len(set(retrieved_ids[:5]) & set(relevant_ids))
all_p5.append(relevant_in_top5 / 5.0)
# Recall@10
relevant_in_top10 = len(set(retrieved_ids[:10]) & set(relevant_ids))
all_r10.append(relevant_in_top10 / len(relevant_ids) if relevant_ids else 0)
# NDCG
true_relevance = [1 if idx in relevant_ids else 0 for idx in retrieved_ids]
predicted_scores = distances[0].tolist()
if sum(true_relevance) > 0:
from sklearn.metrics import ndcg_score
ndcg = ndcg_score([true_relevance], [predicted_scores])
all_ndcg.append(ndcg)
return {
'p@5': np.mean(all_p5),
'ndcg': np.mean(all_ndcg) if all_ndcg else 0.0,
'r@10': np.mean(all_r10)
}
# Avaliar baseline
print("Avaliando modelo baseline...")
# Nota: você precisa passar docs_corpus - corpus completo de documentos
# baseline_results = evaluate_model(baseline_model, test_queries, docs_corpus, ground_truth)
# Avaliar fine-tuned
print("Avaliando modelo fine-tuned...")
# finetuned_results = evaluate_model(finetuned_model, test_queries, docs_corpus, ground_truth)
# Para demonstração, usando valores simulados
# Em produção real, descomente as linhas acima e comente estas
baseline_results = {'p@5': 0.65, 'ndcg': 0.58, 'r@10': 0.72}
finetuned_results = {'p@5': 0.74, 'ndcg': 0.67, 'r@10': 0.79}
# Comparação detalhada
print("\n--- Resultados ---")
print(f"Baseline:")
print(f" Precision@5: {baseline_results['p@5']:.3f}")
print(f" NDCG: {baseline_results['ndcg']:.3f}")
print(f" Recall@10: {baseline_results['r@10']:.3f}")
print(f"\nFine-tuned:")
print(f" Precision@5: {finetuned_results['p@5']:.3f}")
print(f" NDCG: {finetuned_results['ndcg']:.3f}")
print(f" Recall@10: {finetuned_results['r@10']:.3f}")
# Calcular melhorias relativas
p5_improvement = ((finetuned_results['p@5'] - baseline_results['p@5'])
/ baseline_results['p@5'] * 100)
ndcg_improvement = ((finetuned_results['ndcg'] - baseline_results['ndcg'])
/ baseline_results['ndcg'] * 100)
print(f"\n--- Melhoria Relativa ---")
print(f"Precision@5: {p5_improvement:+.1f}%")
print(f"NDCG: {ndcg_improvement:+.1f}%")
# Análise de viabilidade
print(f"\n--- Análise de Viabilidade ---")
if p5_improvement > 10:
print("✓ Melhoria >10% em P@5: Fine-tuning justificado")
else:
print("✗ Melhoria <10% em P@5: Considere otimizações mais simples primeiro")
return {
'baseline': baseline_results,
'finetuned': finetuned_results,
'improvements': {
'p@5': p5_improvement,
'ndcg': ndcg_improvement
}
}Critérios de sucesso quantitativos: A meta mínima deve ser melhoria de >10% em Precision@5 para justificar a complexidade operacional adicional de manter um modelo customizado. Melhorias de 5-10% são limítrofes e podem não compensar o overhead de re-treinamento periódico, monitoramento de drift, e gestão de versões. Melhorias abaixo de 5% sugerem que esforços seriam melhor direcionados para otimizações arquiteturais como implementar hybrid search, melhorar estratégias de chunking, ou aumentar qualidade dos dados de treinamento sintético.
Adicionalmente, realize análise qualitativa inspecionando manualmente 20-30 queries do test set onde houve maior divergência entre baseline e fine-tuned. Isso revela se o modelo está realmente aprendendo padrões úteis do domínio ou apenas memorizando artefatos dos dados sintéticos. Queries onde fine-tuned melhora dramaticamente indicam vocabulário domain-specific sendo capturado; queries onde fine-tuned piora podem revelar overfitting ou viés nos dados de treinamento.
Para a implementação completa de fine-tuning de embeddings com contrastive learning, geração de dados sintéticos e avaliação, veja o Exemplo 6: Fine-tuning de Embeddings.
Embeddings em Sistemas de Agentes
Embeddings são a tecnologia base que viabiliza agentes inteligentes modernos. Enquanto este capítulo focou em fundamentos técnicos, vejamos brevemente como embeddings se integram em arquiteturas de agentes - conceitos que exploraremos profundamente na Parte II do livro.
RAG: Expandindo Conhecimento Além do Treinamento
Retrieval-Augmented Generation (Lewis et al. 2020) resolve uma limitação fundamental de LLMs: conhecimento estático congelado no momento do treinamento. RAG permite que agentes acessem documentos externos dinamicamente, expandindo seu conhecimento para incluir informação proprietária, atualizada, ou específica de domínio.
O pipeline RAG combina tudo que aprendemos neste capítulo:
- Indexação offline: Documentos → chunking → embeddings → vector database
- Retrieval online: Query do usuário → embedding → busca vetorial → top-k chunks relevantes
- Augmentation: Injetar chunks recuperados no prompt do LLM
- Generation: LLM gera resposta fundamentada nos documentos recuperados
def rag_agent(user_query, search_engine, llm):
"""Agente RAG básico: recupera contexto relevante e gera resposta."""
# Recuperar documentos relevantes
relevant_chunks = search_engine.search(user_query, top_k=3)
context = "\n\n".join([chunk['chunk'] for chunk in relevant_chunks])
# Construir prompt com contexto recuperado
prompt = f"""
Baseado nos seguintes documentos, responda a pergunta do usuário.
Se a resposta não está nos documentos, diga que não sabe.
Documentos:
{context}
Pergunta: {user_query}
Resposta:
"""
response = llm.generate(prompt, temperature=0.3)
return response, relevant_chunks # Retornar sources para transparênciaRAG é a arquitetura dominante para assistentes de documentação, chatbots corporativos, e sistemas de Q&A especializados. Exploraremos implementações avançadas no Capítulo 7.
Memory Systems: Contexto Longo via Embeddings
LLMs têm context windows limitados (4K-128K tokens). Para conversas longas ou agentes que operam durante dias, memory systems usam embeddings para armazenar e recuperar contexto histórico relevante seletivamente.
A arquitetura típica mantém dois tipos de memória:
- Working memory: Últimas N turns da conversa (sempre no context window)
- Long-term memory: Histórico completo embedado em vector database
Quando contexto excede limit, o agente busca memories relevantes na long-term memory baseado na query atual, trazendo apenas informação pertinente para o context window.
Tool Retrieval: Seleção Dinâmica em Toolkits Grandes
Agentes com acesso a centenas de ferramentas (APIs, funções) enfrentam um problema: descrever todas as tools no prompt excede o context window. Tool retrieval usa embeddings de descrições de ferramentas para selecionar dinamicamente apenas as relevantes para a task atual (Schick et al. 2023).
# Indexar ferramentas por suas descrições
tool_descriptions = {
"get_weather": "Retorna previsão do tempo para uma cidade",
"send_email": "Envia email para destinatário com subject e body",
"search_database": "Busca registros no banco de dados SQL",
# ... centenas mais
}
# Embedding das descrições
tool_embeddings = model.encode(list(tool_descriptions.values()))
# Para cada task, recuperar top-5 tools mais relevantes
user_task = "Qual a previsão para São Paulo amanhã?"
task_embedding = model.encode([user_task])
distances, indices = index.search(task_embedding, k=5)
relevant_tools = [list(tool_descriptions.keys())[i] for i in indices[0]]
# ["get_weather", "search_location", ...]Essa abordagem escala para milhares de tools, mantendo context window gerenciável.
Multi-Modal Embeddings: Além de Texto
CLIP (Radford et al. 2021) e modelos similares criam embeddings compartilhados entre texto e imagens - uma foto de gato e o texto “gato” têm embeddings próximos. Isso viabiliza busca cross-modal: buscar imagens usando descrições textuais, ou vice-versa.
Para agentes multi-modais que analisam documentos com figuras, dashboards, ou interfaces visuais, embeddings multi-modais permitem raciocínio unificado sobre informação textual e visual.
Classification e Routing: Eficiência via Embeddings
Em sistemas multi-agente (Capítulo 12), embeddings facilitam routing eficiente de queries para agentes especializados. Ao invés de chamar todos os agentes, o router usa embeddings para identificar qual especialista é mais adequado para cada query, economizando latência e custo.
Embeddings também viabilizam zero-shot classification: classificar exemplos em categorias nunca vistas durante treinamento, simplesmente medindo similaridade entre embedding do exemplo e embeddings das descrições das categorias.
Esses conceitos são teasers da Parte II, onde construiremos agentes completos que orquestram todas essas técnicas. Embeddings são a cola que conecta LLMs a conhecimento externo, memória, ferramentas, e percepção multi-modal - transformando modelos estáticos em agentes verdadeiramente inteligentes e adaptativos.
Pratique o que Aprendeu
Para consolidar seu entendimento através de implementação prática, consulte os Exercícios Práticos do Capítulo 6, que incluem:
Semantic Search Engine: Implemente um sistema completo de busca semântica usando FAISS e Sentence Transformers, com chunking paragraph-based, indexação HNSW e avaliação quantitativa (Precision@5, NDCG)
Custom Embedding Fine-tuning: Fine-tune um modelo Sentence Transformer para domínio específico usando dados sintéticos gerados por LLM, comparando baseline vs modelo adaptado
Hybrid Search Implementation: Combine busca semântica (embeddings) com keyword search (BM25), analisando quando cada abordagem é superior e experimentando diferentes pesos de combinação
Conclusão
Neste capítulo, você aprendeu como modelos de linguagem representam significado através de embeddings - a ponte fundamental entre texto e matemática que possibilita sistemas de agentes inteligentes.
O que você aprendeu:
- Fundamentos de Embeddings: Como texto é transformado em vetores que capturam significado semântico
- Modelos Modernos: Sentence Transformers, OpenAI embeddings, e quando usar cada um
- Similaridade Semântica: Métricas e técnicas para medir proximidade entre conceitos
- Chunking Estratégico: Como segmentar documentos preservando contexto
- Vector Databases: Armazenamento e busca eficiente em alta dimensionalidade
- Semantic Search: Implementação completa de busca semântica com re-ranking
- Fine-tuning: Como adaptar embeddings para domínios específicos
- Aplicações em Agentes: Preview de RAG, memory systems e tool retrieval
Com isso, você completou a Parte I: Fundamentos e Componentes Base. Você agora domina:
- Arquitetura: Como Transformers processam informação (Cap 1)
- Treinamento: Como foundation models aprendem (Cap 2)
- Adaptação: Fine-tuning e otimização de modelos (Cap 3)
- Interface: Prompting e raciocínio com LLMs (Cap 4)
- Operacionalização: LLMs em produção (Cap 5)
- Representação: Embeddings e busca semântica (Cap 6)
Na Parte II: Construindo Sistemas de Agentes, você combinará todos esses fundamentos para criar agentes autônomos capazes de:
- Acessar conhecimento externo via RAG (usando embeddings do Cap 6)
- Raciocinar sobre problemas complexos (usando CoT do Cap 4)
- Usar ferramentas externas para realizar tarefas
- Manter memória de longo prazo (usando vector databases)
- Colaborar em sistemas multi-agente
Prepare-se para transformar teoria em agentes de IA funcionais!
Exemplos Completos de Código
Esta seção consolida implementações completas e executáveis dos conceitos apresentados ao longo do capítulo. Todos os códigos incluem imports necessários, docstrings detalhadas, e são prontos para copy-paste-run.
Exemplo 1: Comparação de Métricas de Similaridade
Implementação completa comparando similaridade cosseno, distância euclidiana e Manhattan, demonstrando comportamentos diferentes com e sem normalização.
# uv pip install numpy
import numpy as np
from numpy.linalg import norm
def cosine_similarity(a, b):
"""
Calcula similaridade cosseno entre dois vetores.
A similaridade cosseno mede o ângulo entre vetores, ignorando magnitude.
Resultado entre -1 (opostos) e 1 (idênticos em direção).
Args:
a, b: arrays numpy de mesma dimensão
Returns:
float entre -1 e 1, onde 1 = vetores idênticos em direção
"""
return np.dot(a, b) / (norm(a) * norm(b))
def euclidean_distance(a, b):
"""
Calcula distância euclidiana entre dois vetores.
Considera tanto direção quanto magnitude. Valores menores = mais similar.
Range: [0, ∞)
Args:
a, b: arrays numpy de mesma dimensão
Returns:
float >= 0, onde 0 = vetores idênticos
"""
return norm(a - b)
def manhattan_distance(a, b):
"""
Calcula distância Manhattan (L1) entre dois vetores.
Soma de diferenças absolutas. Mais eficiente que euclidiana mas
menos usado em alta dimensionalidade.
Args:
a, b: arrays numpy de mesma dimensão
Returns:
float >= 0, onde 0 = vetores idênticos
"""
return np.sum(np.abs(a - b))
def l2_normalize(v):
"""
Normalização L2: força ||v|| = 1
Args:
v: array numpy
Returns:
array numpy normalizado
"""
return v / norm(v)
def distance_to_similarity(dist):
"""
Converte distância em score de similaridade [0, 1].
Args:
dist: distância >= 0
Returns:
float onde 1 = idêntico, 0 = infinitamente distante
"""
return 1 / (1 + dist)
# Demonstração com vetores de exemplo
print("=== Comparação de Métricas de Similaridade ===\n")
# Vetores test: mesmo sentido mas magnitudes diferentes
doc1 = np.array([0.5, 0.8, 0.2]) # ||doc1|| = 0.97
doc2 = np.array([0.6, 0.7, 0.3]) # ||doc2|| = 0.98 (direção similar)
doc3 = np.array([-0.5, -0.8, -0.2]) # ||doc3|| = 0.97 (direção oposta)
doc4 = np.array([5.0, 8.0, 2.0]) # ||doc4|| = 9.70 (mesma direção que doc1, 10x maior)
print("Vetores de teste:")
print(f"doc1: {doc1}, ||doc1|| = {norm(doc1):.2f}")
print(f"doc2: {doc2}, ||doc2|| = {norm(doc2):.2f}")
print(f"doc3: {doc3}, ||doc3|| = {norm(doc3):.2f}")
print(f"doc4: {doc4}, ||doc4|| = {norm(doc4):.2f}\n")
# Similaridade Cosseno: ignora magnitude
print("--- Similaridade Cosseno (ignora magnitude) ---")
print(f"cos_sim(doc1, doc2): {cosine_similarity(doc1, doc2):.4f} # Direções similares")
print(f"cos_sim(doc1, doc3): {cosine_similarity(doc1, doc3):.4f} # Direções opostas")
print(f"cos_sim(doc1, doc4): {cosine_similarity(doc1, doc4):.4f} # Mesma direção, magnitudes diferentes\n")
# Distância Euclidiana: considera magnitude
print("--- Distância Euclidiana (considera magnitude) ---")
print(f"euclidean(doc1, doc2): {euclidean_distance(doc1, doc2):.4f} # Próximos")
print(f"euclidean(doc1, doc3): {euclidean_distance(doc1, doc3):.4f} # Distantes")
print(f"euclidean(doc1, doc4): {euclidean_distance(doc1, doc4):.4f} # Mesma direção mas magnitudes muito diferentes\n")
# Manhattan: L1 distance
print("--- Distância Manhattan (L1) ---")
print(f"manhattan(doc1, doc2): {manhattan_distance(doc1, doc2):.4f}")
print(f"manhattan(doc1, doc3): {manhattan_distance(doc1, doc3):.4f}")
print(f"manhattan(doc1, doc4): {manhattan_distance(doc1, doc4):.4f}\n")
# Comparação com vetores normalizados
print("--- Com Normalização L2 ---")
doc1_norm = l2_normalize(doc1)
doc2_norm = l2_normalize(doc2)
doc4_norm = l2_normalize(doc4)
print(f"Após normalização, ||doc1|| = {norm(doc1_norm):.2f}, ||doc4|| = {norm(doc4_norm):.2f}")
print(f"\nCosseno com normalizados:")
print(f" doc1_norm vs doc2_norm: {cosine_similarity(doc1_norm, doc2_norm):.4f}")
print(f" doc1_norm vs doc4_norm: {cosine_similarity(doc1_norm, doc4_norm):.4f} # Agora idênticos!")
print(f"\nDot product com normalizados (equivale a cosseno):")
print(f" np.dot(doc1_norm, doc2_norm): {np.dot(doc1_norm, doc2_norm):.4f}")
print(f" np.dot(doc1_norm, doc4_norm): {np.dot(doc1_norm, doc4_norm):.4f}")Resultados esperados: - Cosseno: doc1 e doc4 têm similaridade ~1.0 (mesma direção) - Euclidiana: doc1 e doc4 têm distância ~8.73 (magnitudes muito diferentes) - Após normalização: doc1 e doc4 se tornam praticamente idênticos
Insight: Para embeddings de texto, use cosseno (ou dot product normalizado) - a direção captura significado, magnitude é acidental.
Exemplo 2: Estratégias de Chunking
Implementação completa das principais estratégias de chunking: fixed-size, sentence-based e paragraph-based.
# uv pip install tiktoken nltk
import tiktoken
import nltk
nltk.download('punkt')
# Usar tokenizer GPT para contagem precisa
tokenizer = tiktoken.get_encoding("cl100k_base")
def fixed_size_chunking(text, chunk_size=512, overlap=50):
"""
Divide texto em chunks de tamanho fixo com overlap.
Simples mas pode cortar sentenças arbitrariamente.
Args:
text: documento completo
chunk_size: tokens por chunk
overlap: tokens compartilhados entre chunks consecutivos
Returns:
Lista de chunks (strings)
"""
tokens = tokenizer.encode(text)
chunks = []
for i in range(0, len(tokens), chunk_size - overlap):
chunk_tokens = tokens[i:i + chunk_size]
chunk_text = tokenizer.decode(chunk_tokens)
chunks.append(chunk_text)
return chunks
def sentence_based_chunking(text, target_size=500, max_size=600):
"""
Agrupa sentenças em chunks respeitando boundaries naturais.
Melhor que fixed-size: não corta sentenças no meio.
Args:
text: documento completo
target_size: número-alvo de tokens por chunk
max_size: máximo absoluto antes de forçar split
Returns:
Lista de chunks (strings)
"""
sentences = nltk.sent_tokenize(text)
chunks = []
current_chunk = []
current_length = 0
for sent in sentences:
sent_length = len(tokenizer.encode(sent))
# Se adicionar sentença excederia max_size, finalizar chunk
if current_length + sent_length > max_size and current_chunk:
chunks.append(" ".join(current_chunk))
current_chunk = []
current_length = 0
current_chunk.append(sent)
current_length += sent_length
# Se atingiu target_size, considerar finalizar chunk
if current_length >= target_size:
chunks.append(" ".join(current_chunk))
current_chunk = []
current_length = 0
# Não esquecer último chunk
if current_chunk:
chunks.append(" ".join(current_chunk))
return chunks
def paragraph_based_chunking(text, target_size=500):
"""
Chunking baseado em parágrafos (separados por \\n\\n).
Ideal para texto bem estruturado. Agrupa parágrafos pequenos,
divide grandes.
Args:
text: documento completo
target_size: tokens-alvo por chunk
Returns:
Lista de chunks (strings)
"""
paragraphs = text.split('\n\n')
chunks = []
current_chunk = []
current_length = 0
for para in paragraphs:
para_length = len(tokenizer.encode(para))
# Parágrafo único muito grande: split em sentenças
if para_length > target_size * 1.5:
# Finalizar chunk atual se não-vazio
if current_chunk:
chunks.append('\n\n'.join(current_chunk))
current_chunk = []
current_length = 0
# Split parágrafo grande em sentenças
chunks.extend(sentence_based_chunking(para, target_size))
continue
# Adicionar parágrafo excederia target_size: finalizar chunk
if current_length + para_length > target_size and current_chunk:
chunks.append('\n\n'.join(current_chunk))
current_chunk = []
current_length = 0
current_chunk.append(para)
current_length += para_length
if current_chunk:
chunks.append('\n\n'.join(current_chunk))
return chunks
# Demonstração com texto exemplo
sample_text = """
# Introdução ao Python
Python é uma linguagem de programação de alto nível, interpretada e de propósito geral. Criada por Guido van Rossum e lançada em 1991, Python enfatiza legibilidade de código e sintaxe que permite aos programadores expressar conceitos em menos linhas de código.
## Características Principais
Python suporta múltiplos paradigmas de programação, incluindo programação orientada a objetos, imperativa e funcional. É dinamicamente tipada e possui gerenciamento automático de memória através de garbage collection.
A linguagem possui uma biblioteca padrão abrangente, frequentemente descrita como "batteries included". Isso significa que Python vem com módulos para tarefas comuns como manipulação de arquivos, networking, e processamento de texto.
## Aplicações
Python é amplamente utilizado em desenvolvimento web através de frameworks como Django e Flask. Na ciência de dados, bibliotecas como NumPy, Pandas e Scikit-learn tornaram Python a linguagem dominante.
Machine learning e inteligência artificial também são áreas onde Python se destaca, com frameworks como TensorFlow, PyTorch e Keras. Automação de tarefas e scripting são outros usos populares devido à simplicidade da sintaxe.
""".strip()
print("=== Comparação de Estratégias de Chunking ===\n")
print(f"Texto original: {len(tokenizer.encode(sample_text))} tokens\n")
# Fixed-size
print("--- Fixed-Size Chunking (512 tokens, overlap=50) ---")
fixed_chunks = fixed_size_chunking(sample_text, chunk_size=512, overlap=50)
print(f"Número de chunks: {len(fixed_chunks)}")
for i, chunk in enumerate(fixed_chunks, 1):
print(f"Chunk {i}: {len(tokenizer.encode(chunk))} tokens")
print(f"Início: {chunk[:80]}...")
print()
# Sentence-based
print("\n--- Sentence-Based Chunking (target=200 tokens) ---")
sent_chunks = sentence_based_chunking(sample_text, target_size=200)
print(f"Número de chunks: {len(sent_chunks)}")
for i, chunk in enumerate(sent_chunks, 1):
print(f"Chunk {i}: {len(tokenizer.encode(chunk))} tokens")
print(f"Início: {chunk[:80]}...")
print()
# Paragraph-based
print("\n--- Paragraph-Based Chunking (target=200 tokens) ---")
para_chunks = paragraph_based_chunking(sample_text, target_size=200)
print(f"Número de chunks: {len(para_chunks)}")
for i, chunk in enumerate(para_chunks, 1):
print(f"Chunk {i}: {len(tokenizer.encode(chunk))} tokens")
print(f"Início: {chunk[:80]}...")
print()Resultados esperados: - Fixed-size: pode cortar no meio de sentenças - Sentence-based: preserva sentenças completas - Paragraph-based: mantém estrutura lógica do documento
Recomendação: Use paragraph-based para documentação estruturada, sentence-based para prosa geral.
Exemplo 3: Semantic Search Engine com FAISS
Sistema completo de busca semântica usando Sentence Transformers e FAISS HNSW.
# uv pip install sentence-transformers faiss-cpu
from sentence_transformers import SentenceTransformer
import faiss
import numpy as np
class SemanticSearchEngine:
"""
Sistema de busca semântica usando embeddings + FAISS.
"""
def __init__(self, model_name='all-mpnet-base-v2'):
"""
Inicializa engine com modelo de embedding.
Args:
model_name: modelo Sentence Transformers a usar
"""
self.model = SentenceTransformer(model_name)
self.dimension = self.model.get_sentence_embedding_dimension()
self.index = None
self.documents = []
def index_documents(self, documents):
"""
Indexa documentos criando embeddings e índice FAISS.
Args:
documents: lista de strings (documentos a indexar)
"""
self.documents = documents
print(f"Gerando embeddings para {len(documents)} documentos...")
embeddings = self.model.encode(
documents,
batch_size=32,
show_progress_bar=True,
normalize_embeddings=True # Para usar dot product = cosine
)
# Criar índice HNSW para baixa latência
print("Construindo índice FAISS HNSW...")
self.index = faiss.IndexHNSWFlat(self.dimension, 32) # M=32 conexões
self.index.add(embeddings.astype('float32'))
print(f"✓ Indexação completa: {len(documents)} documentos")
def search(self, query, top_k=5):
"""
Busca documentos mais relevantes para query.
Args:
query: string de busca
top_k: número de resultados a retornar
Returns:
Lista de (documento, score) ordenados por relevância
"""
# Embedding da query
query_embedding = self.model.encode(
[query],
normalize_embeddings=True
).astype('float32')
# Buscar no índice
distances, indices = self.index.search(query_embedding, top_k)
# Retornar documentos com scores
results = []
for dist, idx in zip(distances[0], indices[0]):
results.append({
'document': self.documents[idx],
'score': float(dist) # Similaridade cosseno [0, 1]
})
return results
# Demonstração com corpus exemplo
documents = [
"Python é uma linguagem de programação de alto nível.",
"JavaScript é usado principalmente para desenvolvimento web.",
"Machine learning envolve treinar modelos em dados.",
"NumPy é uma biblioteca para computação numérica em Python.",
"React é um framework JavaScript para construir UIs.",
"Deep learning usa redes neurais profundas.",
"Pandas facilita manipulação de dados tabulares.",
"Vue.js é um framework progressivo para JavaScript.",
"TensorFlow é uma plataforma para machine learning.",
"Django é um web framework em Python.",
]
# Criar e indexar
engine = SemanticSearchEngine()
engine.index_documents(documents)
# Queries de teste
queries = [
"bibliotecas para ciência de dados",
"frameworks web",
"aprendizado de máquina"
]
print("\n=== Demonstração de Busca Semântica ===\n")
for query in queries:
print(f"Query: '{query}'")
results = engine.search(query, top_k=3)
for i, result in enumerate(results, 1):
print(f" {i}. [Score: {result['score']:.4f}] {result['document']}")
print()Resultados esperados: - Query “bibliotecas para ciência de dados” retorna NumPy, Pandas, TensorFlow - Query “frameworks web” retorna Django, React, Vue.js - Busca captura significado semântico, não apenas keywords
Exemplo 4: Hybrid Search (Semantic + BM25)
Combinação de busca vetorial e keyword search para máxima qualidade.
# uv pip install sentence-transformers faiss-cpu rank-bm25
from sentence_transformers import SentenceTransformer
import faiss
import numpy as np
from rank_bm25 import BM25Okapi
class HybridSearchEngine:
"""
Busca híbrida: semantic (embeddings) + lexical (BM25).
"""
def __init__(self, model_name='all-mpnet-base-v2'):
self.model = SentenceTransformer(model_name)
self.dimension = self.model.get_sentence_embedding_dimension()
self.index = None
self.bm25 = None
self.documents = []
def index_documents(self, documents):
"""Indexação dual: FAISS + BM25."""
self.documents = documents
# Índice vetorial (FAISS)
print("Criando índice FAISS...")
embeddings = self.model.encode(
documents,
normalize_embeddings=True,
show_progress_bar=True
)
self.index = faiss.IndexHNSWFlat(self.dimension, 32)
self.index.add(embeddings.astype('float32'))
# Índice BM25
print("Criando índice BM25...")
tokenized_docs = [doc.lower().split() for doc in documents]
self.bm25 = BM25Okapi(tokenized_docs)
print(f"✓ Indexação híbrida completa: {len(documents)} docs")
def search(self, query, top_k=5, alpha=0.7):
"""
Busca híbrida combinando semantic e keyword.
Args:
query: string de busca
top_k: número de resultados
alpha: peso [0, 1]. 1.0 = só semantic, 0.0 = só BM25
Returns:
Lista de resultados com scores combinados
"""
# Busca semântica
query_emb = self.model.encode(
[query],
normalize_embeddings=True
).astype('float32')
distances, indices = self.index.search(query_emb, top_k*2)
semantic_scores = {indices[0][i]: distances[0][i] for i in range(len(indices[0]))}
# Busca BM25
bm25_scores = self.bm25.get_scores(query.lower().split())
max_bm25 = max(bm25_scores) if max(bm25_scores) > 0 else 1
bm25_scores_norm = bm25_scores / max_bm25 # Normalizar para [0, 1]
# Combinar scores
combined = {}
all_indices = set(range(len(self.documents)))
for idx in all_indices:
sem_score = semantic_scores.get(idx, 0.0)
bm25_score = bm25_scores_norm[idx]
combined[idx] = alpha * sem_score + (1 - alpha) * bm25_score
# Ordenar e retornar top-k
ranked = sorted(combined.items(), key=lambda x: x[1], reverse=True)[:top_k]
results = []
for idx, score in ranked:
results.append({
'document': self.documents[idx],
'score': score,
'semantic_score': semantic_scores.get(idx, 0.0),
'bm25_score': bm25_scores_norm[idx]
})
return results
# Demonstração
documents = [
"O modelo GPT-4 foi lançado pela OpenAI em março de 2023.",
"Machine learning envolve algoritmos que aprendem com dados.",
"Python é amplamente usado em ciência de dados.",
"A versão 3.11 do Python trouxe melhorias de performance.",
"Redes neurais são inspiradas no cérebro humano.",
"FastAPI é um framework web moderno para Python.",
"A empresa OpenAI desenvolveu o ChatGPT.",
"Deep learning requer grandes quantidades de dados.",
]
engine = HybridSearchEngine()
engine.index_documents(documents)
print("\n=== Hybrid Search: Semantic + BM25 ===\n")
# Query com termo específico (favorece BM25)
query1 = "Python 3.11"
print(f"Query 1: '{query1}' (termo específico)")
print("Semantic puro (α=1.0):")
results = engine.search(query1, top_k=3, alpha=1.0)
for i, r in enumerate(results, 1):
print(f" {i}. [Score: {r['score']:.3f}] {r['document'][:60]}...")
print("\nHybrid (α=0.5):")
results = engine.search(query1, top_k=3, alpha=0.5)
for i, r in enumerate(results, 1):
print(f" {i}. [Score: {r['score']:.3f} | Sem:{r['semantic_score']:.3f} BM25:{r['bm25_score']:.3f}]")
print(f" {r['document'][:60]}...")
# Query conceitual (favorece semantic)
query2 = "algoritmos que aprendem automaticamente"
print(f"\n\nQuery 2: '{query2}' (conceitual)")
print("Semantic puro (α=1.0):")
results = engine.search(query2, top_k=3, alpha=1.0)
for i, r in enumerate(results, 1):
print(f" {i}. [Score: {r['score']:.3f}] {r['document'][:60]}...")
print("\nHybrid (α=0.7):")
results = engine.search(query2, top_k=3, alpha=0.7)
for i, r in enumerate(results, 1):
print(f" {i}. [Score: {r['score']:.3f} | Sem:{r['semantic_score']:.3f} BM25:{r['bm25_score']:.3f}]")
print(f" {r['document'][:60]}...")Resultados esperados: - Query “Python 3.11”: Hybrid captura match exato melhor que semantic puro - Query conceitual: Semantic puro já funciona bem, hybrid não degrada - α=0.7 (70% semantic, 30% BM25) é frequentemente o sweet spot
Exemplo 5: Avaliação de Sistemas de Busca
Implementação de métricas Precision@K e NDCG para avaliar qualidade de retrieval.
# uv pip install scikit-learn numpy
import numpy as np
from sklearn.metrics import ndcg_score
def precision_at_k(retrieved, relevant, k=5):
"""
Calcula Precision@K: % de documentos relevantes nos top-K.
Args:
retrieved: lista de doc_ids retornados (ordenados por relevância)
relevant: conjunto de doc_ids relevantes
k: considerar apenas top-K resultados
Returns:
float entre 0 e 1
"""
retrieved_k = set(retrieved[:k])
relevant_set = set(relevant)
relevant_retrieved = len(retrieved_k & relevant_set)
return relevant_retrieved / k
def recall_at_k(retrieved, relevant, k=10):
"""
Calcula Recall@K: % dos documentos relevantes capturados nos top-K.
Args:
retrieved: lista de doc_ids retornados
relevant: conjunto de doc_ids relevantes
k: considerar apenas top-K resultados
Returns:
float entre 0 e 1
"""
retrieved_k = set(retrieved[:k])
relevant_set = set(relevant)
relevant_retrieved = len(retrieved_k & relevant_set)
total_relevant = len(relevant_set)
return relevant_retrieved / total_relevant if total_relevant > 0 else 0
def mean_reciprocal_rank(retrieved_list, relevant_list):
"""
Calcula MRR: quão cedo o primeiro resultado relevante aparece.
Args:
retrieved_list: lista de listas (resultados para cada query)
relevant_list: lista de conjuntos (relevantes para cada query)
Returns:
float: média de 1/rank do primeiro relevante
"""
reciprocal_ranks = []
for retrieved, relevant in zip(retrieved_list, relevant_list):
relevant_set = set(relevant)
for rank, doc_id in enumerate(retrieved, 1):
if doc_id in relevant_set:
reciprocal_ranks.append(1.0 / rank)
break
else:
reciprocal_ranks.append(0.0) # Nenhum relevante encontrado
return np.mean(reciprocal_ranks)
def ndcg_at_k(retrieved, relevant, k=10):
"""
Calcula NDCG@K: métrica que considera relevância e posição.
Args:
retrieved: lista de doc_ids retornados
relevant: conjunto de doc_ids relevantes
k: considerar apenas top-K
Returns:
float entre 0 e 1
"""
retrieved_k = retrieved[:k]
relevant_set = set(relevant)
# Criar vetor de relevância: 1 se relevante, 0 se não
true_relevance = [1 if doc_id in relevant_set else 0
for doc_id in retrieved_k]
# Scores preditos: posição no ranking (maior = melhor)
predicted_scores = list(range(len(true_relevance), 0, -1))
# scikit-learn espera formato 2D
return ndcg_score([true_relevance], [predicted_scores])
def evaluate_search_system(search_function, test_queries, ground_truth):
"""
Avalia sistema de busca em test set completo.
Args:
search_function: função que recebe query e retorna lista de doc_ids
test_queries: lista de queries de teste
ground_truth: lista de listas com doc_ids relevantes por query
Returns:
Dict com métricas agregadas
"""
all_precision_5 = []
all_precision_10 = []
all_recall_10 = []
all_ndcg = []
retrieved_all = []
for query, relevant_docs in zip(test_queries, ground_truth):
# Executar busca
retrieved = search_function(query)
retrieved_all.append(retrieved)
# Calcular métricas
all_precision_5.append(precision_at_k(retrieved, relevant_docs, k=5))
all_precision_10.append(precision_at_k(retrieved, relevant_docs, k=10))
all_recall_10.append(recall_at_k(retrieved, relevant_docs, k=10))
all_ndcg.append(ndcg_at_k(retrieved, relevant_docs, k=10))
# MRR
mrr = mean_reciprocal_rank(retrieved_all, ground_truth)
return {
'precision_at_5': np.mean(all_precision_5),
'precision_at_10': np.mean(all_precision_10),
'recall_at_10': np.mean(all_recall_10),
'ndcg_at_10': np.mean(all_ndcg),
'mrr': mrr,
'num_queries': len(test_queries)
}
# Demonstração com dados sintéticos
print("=== Demonstração de Métricas de Avaliação ===\n")
# Simular resultados de busca
test_queries = [
"machine learning",
"python web frameworks",
"neural networks"
]
# Ground truth: documentos relevantes para cada query
ground_truth = [
[1, 3, 7], # docs relevantes para query 0
[2, 5], # docs relevantes para query 1
[1, 3, 7, 8] # docs relevantes para query 2
]
# Simular função de busca que retorna doc_ids
def mock_search_function(query):
"""Mock: retorna doc_ids pré-definidos."""
if "machine" in query:
return [1, 2, 3, 4, 5, 7, 6, 8, 9, 10] # 3 relevantes nos top-5
elif "python" in query:
return [5, 1, 2, 3, 4, 6, 7, 8, 9, 10] # 2 relevantes nos top-5
else:
return [8, 7, 1, 3, 2, 4, 5, 6, 9, 10] # 4 relevantes nos top-5
# Avaliar sistema
metrics = evaluate_search_system(mock_search_function, test_queries, ground_truth)
print("Métricas de Performance:")
print(f" Precision@5: {metrics['precision_at_5']:.3f}")
print(f" Precision@10: {metrics['precision_at_10']:.3f}")
print(f" Recall@10: {metrics['recall_at_10']:.3f}")
print(f" NDCG@10: {metrics['ndcg_at_10']:.3f}")
print(f" MRR: {metrics['mrr']:.3f}")
print(f" Queries: {metrics['num_queries']}")
print("\n\nDetalhamento por Query:")
for i, (query, relevant) in enumerate(zip(test_queries, ground_truth)):
retrieved = mock_search_function(query)
print(f"\nQuery {i+1}: '{query}'")
print(f" Relevantes esperados: {relevant}")
print(f" Top-5 retornados: {retrieved[:5]}")
print(f" Precision@5: {precision_at_k(retrieved, relevant, k=5):.3f}")
print(f" NDCG@10: {ndcg_at_k(retrieved, relevant, k=10):.3f}")Resultados esperados: - Precision@5 mede qualidade dos primeiros resultados (crítico para UX) - Recall@10 mede cobertura (importante para garantir não perder informação) - NDCG pondera posição: relevantes no topo pontuam mais - MRR foca no primeiro resultado relevante (útil para Q&A)
Meta para produção: Precision@5 > 0.70, NDCG@10 > 0.60
Exemplo 6: Fine-tuning de Embeddings
Fine-tuning de Sentence Transformer usando contrastive learning com MultipleNegativesRankingLoss.
# uv pip install sentence-transformers torch
from sentence_transformers import SentenceTransformer, losses, InputExample, evaluation
from torch.utils.data import DataLoader
import numpy as np
# Preparar dados de treino
# Formato: InputExample(texts=[query, documento_relevante])
# Negativos são criados automaticamente do batch
train_examples = [
InputExample(texts=[
"Como instalar bibliotecas em Python?",
"Use pip install nome-da-biblioteca para instalar pacotes Python."
]),
InputExample(texts=[
"O que é machine learning?",
"Machine learning é um subcampo da IA que permite sistemas aprenderem com dados."
]),
InputExample(texts=[
"Como criar função em Python?",
"Use a palavra-chave def seguida do nome da função e parênteses: def minha_funcao():"
]),
InputExample(texts=[
"Diferença entre lista e tupla?",
"Listas são mutáveis (podem ser modificadas), tuplas são imutáveis."
]),
InputExample(texts=[
"O que é deep learning?",
"Deep learning usa redes neurais profundas para aprender representações hierárquicas."
]),
# Em produção: 1000+ exemplos
]
# Preparar dados de validação
dev_examples = [
InputExample(texts=[
"instalar pacotes python",
"Use pip install para instalar pacotes."
]),
InputExample(texts=[
"o que é aprendizado de máquina",
"Machine learning permite sistemas aprenderem automaticamente."
]),
]
print("=== Fine-tuning de Embedding Model ===\n")
# Carregar modelo base
model_name = 'all-MiniLM-L6-v2'
print(f"Carregando modelo base: {model_name}")
model = SentenceTransformer(model_name)
# Preparar DataLoader
train_dataloader = DataLoader(
train_examples,
shuffle=True,
batch_size=16
)
# MultipleNegativesRankingLoss (implementa InfoNCE)
# Com batch=16, cada exemplo vê 15 negativos automaticamente
train_loss = losses.MultipleNegativesRankingLoss(model=model)
# Evaluator para validação
evaluator = evaluation.EmbeddingSimilarityEvaluator.from_input_examples(
dev_examples,
name='dev'
)
# Fine-tune
print("Iniciando fine-tuning...")
model.fit(
train_objectives=[(train_dataloader, train_loss)],
epochs=3,
warmup_steps=100,
output_path='./models/finetuned-embedding',
show_progress_bar=True,
evaluator=evaluator,
evaluation_steps=500
)
print("\n✓ Fine-tuning completo!")
# Comparar baseline vs fine-tuned
print("\n=== Comparação Baseline vs Fine-tuned ===\n")
baseline_model = SentenceTransformer(model_name)
finetuned_model = SentenceTransformer('./models/finetuned-embedding')
# Queries de teste
test_queries = [
"como usar pip",
"explicação de redes neurais",
"diferença lista tupla python"
]
test_documents = [
"Use pip install para instalar pacotes Python.",
"Machine learning envolve algoritmos que aprendem com dados.",
"Listas são mutáveis, tuplas são imutáveis em Python.",
"Deep learning usa redes neurais profundas.",
"A função def define uma função em Python."
]
for query in test_queries:
print(f"Query: '{query}'")
# Baseline
query_emb_base = baseline_model.encode([query])
doc_embs_base = baseline_model.encode(test_documents)
similarities_base = np.dot(query_emb_base, doc_embs_base.T)[0]
best_base = np.argmax(similarities_base)
# Fine-tuned
query_emb_ft = finetuned_model.encode([query])
doc_embs_ft = finetuned_model.encode(test_documents)
similarities_ft = np.dot(query_emb_ft, doc_embs_ft.T)[0]
best_ft = np.argmax(similarities_ft)
print(f" Baseline melhor: [{similarities_base[best_base]:.3f}] {test_documents[best_base]}")
print(f" Fine-tuned melhor: [{similarities_ft[best_ft]:.3f}] {test_documents[best_ft]}")
print()Resultados esperados: - Fine-tuned model aprende vocabulário e padrões do domínio - Melhoria de 10-20% em similaridades para queries do domínio - Trade-off: overfitting se dataset muito pequeno (<1K exemplos)
Produção: Use 5K-10K+ exemplos, validação rigorosa, e compare com baseline em test set independente.
Esses exemplos consolidam as técnicas fundamentais de embeddings e semantic search. Para aprofundar, consulte os Exercícios Práticos do Capítulo 6, que incluem implementações hands-on de sistemas completos.