Embeddings em RAG

Por Réulison Silva
Réulison Silva
Published on
Embeddings em RAG

1. O que é um RAG?

O RAG (Retrieval-Augmented Generation) é uma técnica que conecta IAs generativas (como LLMs) a bases de dados externas. Em vez de depender apenas do que a IA aprendeu no treinamento, ela busca documentos ou dados específicos, garantindo respostas mais precisas, atualizadas e com menos "alucinações".

2. O que é um Embedding?

2.1 Definição Fundamental

Um embedding é uma representação matemática de dados não estruturados — como texto, imagens, áudio ou vídeo — na forma de um vetor denso de números de ponto flutuante. Este vetor é gerado por um modelo de aprendizado de máquina treinado para capturar o significado semântico e as relações contextuais dos dados originais.

Exemplo: Busca semântica com IA no PostgreSQL com pgvector

Analogia do Mapa Mundi

Imagine um mapa-múndi onde cada palavra, frase ou documento é uma cidade. Cidades com culturas, idiomas e histórias semelhantes estão próximas geograficamente. Da mesma forma, no espaço vetorial de embeddings, textos com significados parecidos ocupam posições próximas.

2.2 Estrutura de um Embedding

Um embedding é um array unidimensional:

Texto: "Deep learning is transforming the world."

Embedding (Vetor de 384 dimensões):
[0.21, -0.47, 0.13, 0.89, -0.32, 0.56, ..., 0.65]
  ↑      ↑      ↑      ↑      ↑      ↑          ↑
 D1     D2     D3     D4     D5     D6        D384

Características Técnicas:

  • Alta Dimensionalidade: 384, 768, 1536 ou até 3072 dimensões.
  • Valores Reais: Números entre -1 e 1 (após normalização).
  • Densidade: Poucos zeros; a maioria das dimensões contém informações.
  • Contextualidade: O mesmo texto pode ter embeddings diferentes em contextos diferentes (ex: "banco" como instituição financeira vs. assento).

2.3 Propriedades Fundamentais

2.3.1 Captura de Significado Semântico

O embedding não representa palavras isoladas, mas significado contextual. Por exemplo:

Python
from sentence_transformers import SentenceTransformer
from sklearn.metrics.pairwise import cosine_similarity

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

# Palavras com significados diferentes
textos = [
    "banco instituição financeira",
    "banco para sentar",
    "gato animal doméstico"
]

embeddings = modelo.encode(textos)

# Similaridade entre os dois "bancos" (significados diferentes)
sim_banco_financeiro_vs_assento = cosine_similarity(
    [embeddings[0]], [embeddings[1]]
)[0][0]  # ~0.15 (baixa)

# Similaridade entre "banco financeiro" e "gato" (nenhuma relação)
sim_banco_vs_gato = cosine_similarity(
    [embeddings[0]], [embeddings[2]]
)[0][0]  # ~0.02 (muito baixa)
2.3.2 Vetores Semelhantes para Significados Semelhantes

A distância entre vetores reflete a similaridade semântica:

Par de TextosSimilaridade Cosseno
"Cachorro" e "Cão"0.92
"Cachorro" e "Gato"0.65
"Cachorro" e "Carro"0.12
Representação tridimensional de embeddings semânticos em um espaço vetorial, mostrando como palavras de categorias semelhantes (animais, veículos e alimentos) formam clusters próximos, enquanto conceitos semanticamente diferentes permanecem mais distantes. A tabela de similaridade por cosseno exemplifica essa relação entre os vetores.
Representação tridimensional de embeddings semânticos em um espaço vetorial, mostrando como palavras de categorias semelhantes (animais, veículos e alimentos) formam clusters próximos, enquanto conceitos semanticamente diferentes permanecem mais distantes. A tabela de similaridade por cosseno exemplifica essa relação entre os vetores.
2.3.3 Operações Algébricas

Embeddings permitem operações vetoriais que refletem analogias semânticas:

Vetor("Rei") - Vetor("Homem") + Vetor("Mulher") ≈ Vetor("Rainha")v

2.4 Casos de Uso Detalhados

Caso de UsoComo os Embeddings AjudamExemplo Real
Busca SemânticaEncontra documentos pelo significado, não por palavras-chaveGoogle Search (BERT)
Recuperação em RAGSeleciona contexto relevante para o LLMChatbots empresariais
ClusterizaçãoAgrupa documentos similares automaticamenteOrganização de bibliotecas digitais
RecomendaçãoSugere itens baseados em similaridade de conteúdoNetflix, Amazon
Detecção de PlágioCompara similaridade entre textosTurnitin
Tradução AutomáticaAlinha representações entre idiomasGoogle Translate

2.5 Exemplo Prático: Busca Semântica Simples

Python
from sentence_transformers import SentenceTransformer, util

# 1. Textos de documentos
textos = [
    "A inteligência artificial está transformando a medicina.",
    "Carros elétricos são o futuro do transporte sustentável.",
    "O aquecimento global é causado pela emissão de gases.",
    "Redes neurais profundas aprendem representações hierárquicas."
]

# 2. Query do usuário
query = "Como a IA está mudando a saúde?"

# 3. Carrega modelo e gera embeddings
modelo = SentenceTransformer('all-MiniLM-L6-v2')
embeddings_textos = modelo.encode(textos)
embedding_query = modelo.encode(query)

# 4. Calcula similaridades
similaridades = util.cos_sim(embedding_query, embeddings_textos)[0]

# 5. Recupera os 2 documentos mais relevantes
top_k = 2
indices = similaridades.argsort(descending=True)[:top_k]

print("Resultados da busca semântica:")
for idx in indices:
    print(f"- {textos[idx]} (Score: {similaridades[idx]:.4f})")

Saída Esperada:

- A inteligência artificial está transformando a medicina. (Score: 0.87)
- Redes neurais profundas aprendem representações hierárquicas. (Score: 0.52)

3. Como os Embeddings Funcionam

3.1 O Processo de Transformação

Um modelo de embedding é uma rede neural profunda (tipicamente um transformer) treinada para mapear texto em vetores. O processo envolve:

  • Tokenização: O texto é dividido em tokens (palavras ou subpalavras).
  • Codificação: Cada token recebe um embedding inicial (posicional + semântico).
  • Processamento: Camadas de atenção calculam relações entre tokens.
  • Pooling: Os tokens são combinados em um único vetor (média, CLS token, etc.).
  • Normalização: O vetor final é normalizado para norma unitária.
Arquitetura de um transformer
Arquitetura de um transformer

3.2 Espaço Vetorial e Distâncias

O espaço vetorial é um espaço matemático de alta dimensão onde:

  • Cada dimensão representa um "aspecto" abstrato do significado.
  • Textos semelhantes formam clusters (agrupamentos).
  • Distâncias refletem diferenças semânticas.

Métricas de Distância Comuns:

Python
import numpy as np
from scipy.spatial.distance import cosine, euclidean

# Exemplo de vetores
v1 = np.array([0.5, 0.3, -0.2])
v2 = np.array([0.4, 0.2, -0.1])
v3 = np.array([-0.9, 0.1, 0.8])

# Similaridade por cosseno (1 = idêntico, 0 = perpendicular, -1 = oposto)
cos_sim = 1 - cosine(v1, v2)  # ≈ 0.95

# Distância Euclidiana (menor = mais similar)
dist_euclidiana = euclidean(v1, v2)  # ≈ 0.17

# Produto Escalar (útil para vetores normalizados)
dot_product = np.dot(v1, v2)  # ≈ 0.29

SciPy documentation

3.3 O Papel do Modelo de Embedding

O modelo é o "tradutor" entre linguagem natural e espaço vetorial. Diferentes modelos produzem diferentes representações:

Python
# Comparando modelos
modelo1 = SentenceTransformer('all-MiniLM-L6-v2')  # 384 dims
modelo2 = SentenceTransformer('all-mpnet-base-v2')  # 768 dims

texto = "Deep learning basics"

emb1 = modelo1.encode(texto)
emb2 = modelo2.encode(texto)

print(f"Dimensões: {len(emb1)} vs {len(emb2)}")
print(f"Norma emb1: {np.linalg.norm(emb1):.4f}")  # ~1.0 (normalizado)
print(f"Norma emb2: {np.linalg.norm(emb2):.4f}")  # ~1.0 (normalizado)

3.4 Medindo Similaridade na Prática

Python
def calcular_similaridade(texto1, texto2, modelo):
    """Calcula similaridade semântica entre dois textos."""
    emb1 = modelo.encode(texto1)
    emb2 = modelo.encode(texto2)
    return cosine_similarity([emb1], [emb2])[0][0]

# Teste com diferentes pares
pares = [
    ("O gato dormiu no sofá.", "O felino descansou no estofado."),
    ("O gato dormiu no sofá.", "O carro vermelho acelerou."),
    ("Python é uma linguagem de programação.", "Java é outra linguagem popular.")
]

for t1, t2 in pares:
    sim = calcular_similaridade(t1, t2, modelo)
    print(f"'{t1[:20]}...' vs '{t2[:20]}...' → {sim:.3f}")

3.5 Por que Embeddings são Eficazes?

  • Generalização: Capturam sinônimos e variações linguísticas.
  • Contextualização: Palavras mudam de significado conforme o contexto.
  • Compressão: Resumem significado em vetores densos.
  • Eficiência: Buscas vetoriais são mais rápidas que buscas textuais complexas.

3.6 Desafios e Limitações

DesafioDescriçãoSolução
Palavras fora do vocabulário (OOV)Palavras não vistas no treinoTokenização por subpalavras (BPE)
Viés culturalEmbeddings refletem vieses dos dados de treinoFine-tuning com dados balanceados
Contexto limitadoAlguns modelos ignoram contexto longoUsar modelos com janela de contexto maior
Custo computacionalModelos grandes são lentosUsar versões destiladas ou quantizadas

4. Embeddings no Pipeline RAG

4.1 O que é RAG (Retrieval-Augmented Generation)?

RAG é uma arquitetura que combina:

Sistema de Recuperação (Retriever): Encontra documentos relevantes usando embeddings.

Sistema de Geração (Generator): Um LLM (ex: GPT, LLaMA) que gera respostas baseadas no contexto recuperado.

Por que RAG?
  • Reduz Alucinações: O LLM tem fatos concretos para basear suas respostas.
  • Atualização Dinâmica: A base de conhecimento pode ser atualizada sem re-treinar o modelo.
  • Transparência: As fontes recuperadas podem ser citadas.

4.2 Pipeline Completo do RAG com Embeddings

Fluxograma completo do RAG com embeddings
Fluxograma completo do RAG com embeddings

4.3 Implementação Passo a Passo

Passo 1: Preparação da Base de Conhecimento

Python
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.document_loaders import TextLoader

# Carrega documentos
loader = TextLoader("base_conhecimento.txt")
documentos = loader.load()

# Divide em chunks semânticos
splitter = RecursiveCharacterTextSplitter(
    chunk_size=500,
    chunk_overlap=50,
    separators=["\n\n", "\n", ".", " "],
    length_function=len,
)

chunks = splitter.split_documents(documentos)
print(f"Total de chunks: {len(chunks)}")

Passo 2: Geração dos Embeddings e Indexação

Python
from sentence_transformers import SentenceTransformer
import faiss
import numpy as np

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

# Gera embeddings para todos os chunks
textos_chunks = [chunk.page_content for chunk in chunks]
embeddings_chunks = modelo.encode(textos_chunks)

# Cria índice FAISS para busca eficiente
dimensao = embeddings_chunks.shape[1]
indice = faiss.IndexFlatIP(dimensao)  # Produto interno (similaridade)

# Normaliza embeddings para usar similaridade por cosseno
embeddings_normalizados = embeddings_chunks / np.linalg.norm(embeddings_chunks, axis=1, keepdims=True)
indice.add(embeddings_normalizados.astype('float32'))

print(f"Índice criado com {indice.ntotal} vetores")

Passo 3: Recuperação de Contexto

Python
def recuperar_contexto(query, indice, modelo, chunks, k=3):
    """Recupera os K chunks mais relevantes para uma query."""
    # Embedding da query
    emb_query = modelo.encode(query)
    emb_query_normalizado = emb_query / np.linalg.norm(emb_query)
    
    # Busca no índice
    similaridades, indices = indice.search(
        emb_query_normalizado.reshape(1, -1).astype('float32'), 
        k
    )
    
    # Recupera os chunks
    contextos = []
    for idx, score in zip(indices[0], similaridades[0]):
        if idx != -1:
            contextos.append({
                'texto': chunks[idx].page_content,
                'score': score
            })
    
    return contextos

# Teste
query = "Quais são as aplicações do aprendizado por transferência?"
contextos = recuperar_contexto(query, indice, modelo, chunks, k=3)

for i, ctx in enumerate(contextos, 1):
    print(f"\nContexto {i} (Score: {ctx['score']:.4f}):")
    print(ctx['texto'][:200] + "...")

Passo 4: Geração da Resposta com LLM

Python
from transformers import pipeline

# Carrega um modelo generativo (exemplo com GPT-2)
gerador = pipeline('text-generation', model='gpt2')

def gerar_resposta(query, contextos, gerador):
    """Gera resposta baseada no contexto recuperado."""
    # Constrói prompt
    contexto_texto = "\n\n".join([ctx['texto'] for ctx in contextos])
    
    prompt = f"""
    Contexto:
    {contexto_texto}
    
    Pergunta: {query}
    
    Resposta baseada APENAS no contexto fornecido:
    """
    
    # Gera resposta
    resposta = gerador(
        prompt,
        max_length=300,
        num_return_sequences=1,
        temperature=0.7,
        pad_token_id=50256
    )[0]['generated_text']
    
    return resposta

# Gera resposta
resposta = gerar_resposta(query, contextos, gerador)
print("\nResposta Gerada:")
print(resposta)

4.4 Benefícios dos Embeddings no RAG

BenefícioExplicaçãoImpacto
Recuperação SemânticaEncontra documentos pelo significado, não por palavras exatas+40% de precisão em perguntas complexas
EscalabilidadeBuscas em milhões de documentos em milissegundosSuporte a bases de conhecimento gigantes
FlexibilidadeFunciona com qualquer LLM (GPT, LLaMA, Claude)Independência de fornecedor
Atualização ContínuaBasta re-embedar novos documentosConhecimento sempre atualizado
Redução de AlucinaçõesFornece fatos concretos para o LLM-70% de respostas incorretas

4.5 Métricas de Avaliação do RAG

Python
def avaliar_recuperacao(queries, relevantes, indice, modelo, chunks, k=5):
    """Avalia a qualidade da recuperação com Precision@K e Recall@K."""
    
    precision_k_total = 0
    recall_k_total = 0
    
    for query, docs_relevantes in zip(queries, relevantes):
        # Recupera documentos
        contextos = recuperar_contexto(query, indice, modelo, chunks, k)
        docs_recuperados = [ctx['texto'] for ctx in contextos]
        
        # Calcula Precision@K
        relevantes_recuperados = sum(1 for doc in docs_recuperados if doc in docs_relevantes)
        precision_k = relevantes_recuperados / k
        precision_k_total += precision_k
        
        # Calcula Recall@K
        recall_k = relevantes_recuperados / len(docs_relevantes)
        recall_k_total += recall_k
    
    return {
        'Precision@K': precision_k_total / len(queries),
        'Recall@K': recall_k_total / len(queries)
    }

# Exemplo de uso
queries = ["O que é transfer learning?", "Explique redes neurais"]
relevantes = [
    ["Documento sobre transfer learning", "Artigo sobre fine-tuning"],
    ["Introdução a redes neurais", "Deep learning book"]
]

metricas = avaliar_recuperacao(queries, relevantes, indice, modelo, chunks)
print(f"Precision@5: {metricas['Precision@K']:.3f}")
print(f"Recall@5: {metricas['Recall@K']:.3f}")

4.6 Otimizações Avançadas

4.6.1 Re-ranking

Após a recuperação inicial, use um modelo mais caro (ex: cross-encoder) para reordenar os documentos.

Python
from sentence_transformers import CrossEncoder

# Modelo cross-encoder para re-ranking
re_ranker = CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')

def re_rank(query, documentos, re_ranker, top_k=3):
    """Re-rank usando cross-encoder."""
    pares = [[query, doc] for doc in documentos]
    scores = re_ranker.predict(pares)
    
    # Ordena por score
    documentos_ranqueados = sorted(
        zip(documentos, scores),
        key=lambda x: x[1],
        reverse=True
    )
    
    return documentos_ranqueados[:top_k]

5. Modelos de Embedding

5.1 Visão Geral dos Modelos

Modelos de embedding são a espinha dorsal de todo sistema RAG. Eles determinam a qualidade da recuperação, a velocidade e o custo operacional.

ModeloDimensãoTamanho (MB)Velocidade (token/ms)Precisão (MTEB)CustoMelhor para
all-MiniLM-L6-v2384908.558.3GratuitoUso geral, prototipagem
all-mpnet-base-v27684204.263.8GratuitoAlta precisão com recursos
text-embedding-3-small1536-2.162.3$0.02/1M tokensProdução com custo moderado
text-embedding-3-large3072-1.064.6$0.13/1M tokensAlta qualidade, pesquisas críticas
e5-large-v210241.3GB1.866.2GratuitoMultilíngue e pesquisa acadêmica
BAAI/bge-large-en-v1.510241.3GB1.964.2GratuitoDomínio geral, alta precisão
cohere-embed-english-v31024-2.561.0$0.10/1M tokensEmpresas com suporte dedicado
voyage-21024-2.862.1$0.12/1M tokensFine-tuning específico
 Gráfico de barras comparando dimensão vs. precisão dos modelos
Gráfico de barras comparando dimensão vs. precisão dos modelos

5.3 Como Escolher o Modelo Certo

Critérios de Decisão:
  • Precisão Necessária:

    • Para chatbots simples → MiniLM é suficiente.

    • Para pesquisa acadêmica → e5-large ou text-embedding-3-large.

  • Orçamento:

    • Open-source (gratuito): Sentence-Transformers, E5.

    • API paga: OpenAI, Cohere, VoyageAI.

  • Latência:

    • Aplicações em tempo real → Modelos pequenos (384 dims).

    • Processamento batch → Modelos grandes (1024+ dims).

  • Idioma:

    • Apenas inglês → Qualquer modelo.

    • Multilíngue → e5-large, mBERT, XLM-R.

  • Domínio Específico:

    • Medicina → PubMedBERT fine-tunado.

    • Jurídico → LegalBERT.

    • Finanças → FinBERT.

5.4 Implementação com Diferentes Modelos

Python
from sentence_transformers import SentenceTransformer
import time

def benchmark_model(model_name, textos, num_iter=100):
    """Avalia velocidade e qualidade de um modelo."""
    
    # Carrega modelo
    modelo = SentenceTransformer(model_name)
    
    # Mede tempo de encoding
    start = time.time()
    for _ in range(num_iter):
        _ = modelo.encode(textos, show_progress_bar=False)
    tempo_total = time.time() - start
    
    # Gera embeddings
    embeddings = modelo.encode(textos)
    
    # Calcula similaridades
    from sklearn.metrics.pairwise import cosine_similarity
    sim_matrix = cosine_similarity(embeddings)
    
    return {
        'nome': model_name,
        'dimensão': embeddings.shape[1],
        'tempo_médio': tempo_total / num_iter,
        'similaridade_média': sim_matrix.mean()
    }

# Teste com diferentes modelos
textos_teste = [
    "O cachorro está feliz.",
    "O cão está contente.",
    "O carro está rápido.",
    "O veículo é veloz."
]

modelos = ['all-MiniLM-L6-v2', 'all-mpnet-base-v2', 'paraphrase-multilingual-MiniLM-L12-v2']

for modelo in modelos:
    resultado = benchmark_model(modelo, textos_teste)
    print(f"\n{resultado['nome']}:")
    print(f"  Dimensão: {resultado['dimensão']}")
    print(f"  Tempo médio: {resultado['tempo_médio']:.4f}s")
    print(f"  Similaridade média: {resultado['similaridade_média']:.4f}")

5.5 Fine-tuning de Modelos de Embedding

Para domínios específicos, você pode fine-tunar um modelo de embedding:

Python
from sentence_transformers import SentenceTransformer, InputExample, losses
from torch.utils.data import DataLoader
import random

# 1. Dados de exemplo (pares similares/dissimilares)
exemplos_treino = []
with open('dados_finetuning.txt', 'r') as f:
    for linha in f:
        texto1, texto2, label = linha.strip().split('\t')
        exemplo = InputExample(texts=[texto1, texto2], label=float(label))
        exemplos_treino.append(exemplo)

# 2. Carrega modelo base
modelo = SentenceTransformer('all-MiniLM-L6-v2')

# 3. Configura treinamento
train_dataloader = DataLoader(exemplos_treino, shuffle=True, batch_size=16)
loss = losses.CosineSimilarityLoss(modelo)

# 4. Fine-tuning
modelo.fit(
    train_objectives=[(train_dataloader, loss)],
    epochs=3,
    warmup_steps=100,
    output_path='modelo_finetuned',
    show_progress_bar=True
)

print("Fine-tuning concluído!")

5.6 Armazenamento e Serialização

Python
import pickle
import numpy as np

# Salvar embeddings para uso futuro
def salvar_embeddings(embeddings, caminho):
    with open(caminho, 'wb') as f:
        pickle.dump(embeddings, f)

# Carregar embeddings
def carregar_embeddings(caminho):
    with open(caminho, 'rb') as f:
        return pickle.load(f)

# Exemplo
salvar_embeddings(embeddings_chunks, 'embeddings_base.pkl')
embeddings_carregados = carregar_embeddings('embeddings_base.pkl')
print(f"Embeddings carregados: {embeddings_carregados.shape}")

6. Busca por Similaridade

6.1 Fundamentos da Busca Vetorial

A busca por similaridade é o mecanismo que permite encontrar rapidamente os documentos mais relevantes em um espaço vetorial de alta dimensão.

O Problema: Dado um vetor query, encontre os K vetores mais próximos em um banco de milhões de vetores.

Solução Ingênua: Força bruta (O(n) - impraticável para grandes bases).

Solução Eficiente: ANN (Approximate Nearest Neighbor) - O(log n) ou O(1).

6.2 Métricas de Similaridade em Detalhe

6.2.1 Similaridade por Cosseno (Mais Comum)
cos(θ) = (A · B) / (||A|| · ||B||)
  • Range: [-1, 1]
  • Interpretação: 1 = mesmíssimo significado, 0 = ortogonal, -1 = oposto
  • Vantagem: Invariante à magnitude dos vetores
  • Uso: Texto, onde a direção importa mais que o comprimento
Python
def cosine_similarity(v1, v2):
    """Implementação manual da similaridade por cosseno."""
    dot = sum(a * b for a, b in zip(v1, v2))
    norm1 = sum(a**2 for a in v1) ** 0.5
    norm2 = sum(b**2 for b in v2) ** 0.5
    return dot / (norm1 * norm2) if norm1 and norm2 else 0.0

v1 = [0.5, 0.3, -0.2]
v2 = [0.4, 0.2, -0.1]
print(f"Similaridade: {cosine_similarity(v1, v2):.4f}")
6.2.2 Produto Escalar (Dot Product)
dot(A, B) = Σ(Ai * Bi)
  • Range: [-∞, ∞]
  • Interpretação: Similaridade baseada na magnitude e direção
  • Vantagem: Mais rápido que cosseno para vetores normalizados
  • Uso: FAISS com vetores normalizados (equivalente ao cosseno)
6.2.3 Distância Euclidiana
d(A, B) = √(Σ(Ai - Bi)²)
  • Range: [0, ∞]
  • Interpretação: Distância geométrica direta
  • Vantagem: Intuitiva para clusters
  • Uso: Clusterização, mas menos comum para similaridade de texto

5.3 Algoritmos ANN (Approximate Nearest Neighbor)

5.3.1 FAISS (Facebook AI Similarity Search)

FAISS é a biblioteca mais popular para busca vetorial eficiente.

Python
import faiss
import numpy as np

# 1. Cria dados de exemplo
np.random.seed(42)
dados = np.random.random((10000, 128)).astype('float32')
query = np.random.random((1, 128)).astype('float32')

# 2. Índices FAISS

# Força bruta (exata, lenta)
indice_flat = faiss.IndexFlatL2(128)
indice_flat.add(dados)
distancias, indices = indice_flat.search(query, 5)
print("Força Bruta:", indices[0])

# IVF (Inverted File) - divide o espaço em células
quantizer = faiss.IndexFlatL2(128)
indice_ivf = faiss.IndexIVFFlat(quantizer, 128, 100)  # 100 células
indice_ivf.train(dados)
indice_ivf.add(dados)
indice_ivf.nprobe = 10  # Número de células a explorar
distancias, indices = indice_ivf.search(query, 5)
print("IVF:", indices[0])

# HNSW (Hierarchical Navigable Small World) - grafo hierárquico
indice_hnsw = faiss.IndexHNSWFlat(128, 32)  # 32 conexões
indice_hnsw.add(dados)
distancias, indices = indice_hnsw.search(query, 5)
print("HNSW:", indices[0])
6.3.2 HNSW (Hierarchical Navigable Small World)

HNSW constrói um grafo hierárquico onde cada nível tem menos nós.

Vantagens:
  • Excelente precisão (perto da busca exata)
  • Muito rápido
  • Não precisa de treinamento
Desvantagens:
  • Alto consumo de memória
  • Construção mais lenta que IVF
Python
# Configurações comuns para HNSW
# efConstruction = 200 (qualidade da construção)
# M = 16-64 (número de conexões)
# efSearch = 100-200 (profundidade da busca)

indice_hnsw_otimizado = faiss.IndexHNSWFlat(128, 64)
indice_hnsw_otimizado.hnsw.efConstruction = 200
indice_hnsw_otimizado.add(dados)
# Para busca, ajustar efSearch
indice_hnsw_otimizado.hnsw.efSearch = 150
6.3.3 IVF (Inverted File Index)

IVF divide o espaço em células usando clustering (k-means).

Vantagens:
  • Muito eficiente em memória
  • Escala para bilhões de vetores
  • Treinamento rápido
Desvantagens:
  • Requer treinamento
  • Precisão dependente do número de células
Python
# IVF com compressão (PQ - Product Quantization)
# Para economizar memória ainda mais
n_cells = 4096  # Número de células
n_subquantizers = 64  # Subquantizadores para PQ

quantizer = faiss.IndexFlatL2(128)
indice_ivfpq = faiss.IndexIVFPQ(quantizer, 128, n_cells, n_subquantizers, 8)
indice_ivfpq.train(dados)
indice_ivfpq.add(dados)
indice_ivfpq.nprobe = 20

print(f"Memória: {indice_ivfpq.sa_code_size()} bytes por vetor")

6.4 Comparação de Performance

Python
import time
import pandas as pd

def benchmark_indices(dados, query, indices, k=10):
    """Compara performance de diferentes índices."""
    resultados = []
    
    for nome, indice in indices:
        # Mede tempo de busca
        start = time.time()
        _, _ = indice.search(query, k)
        tempo = time.time() - start
        
        resultados.append({
            'Índice': nome,
            'Tempo (ms)': tempo * 1000,
            'Memória (MB)': indice.sa_code_size() * indice.ntotal / (1024 * 1024) if hasattr(indice, 'sa_code_size') else None
        })
    
    return pd.DataFrame(resultados)

# Criando índices para benchmark
indices_benchmark = [
    ('Flat (Exato)', faiss.IndexFlatL2(128)),
    ('HNSW (M=32)', faiss.IndexHNSWFlat(128, 32)),
    ('IVF (n_cells=100)', faiss.IndexIVFFlat(faiss.IndexFlatL2(128), 128, 100)),
]

# Configura índices
for nome, idx in indices_benchmark:
    if 'IVF' in nome:
        idx.train(dados)
    idx.add(dados)

# Benchmark
df_resultados = benchmark_indices(dados, query, indices_benchmark)
print(df_resultados)

6.5 Indexação em Banco de Dados Vetoriais

Milvus - Banco Vetorial Distribuído
Python
from pymilvus import Collection, CollectionSchema, FieldSchema, DataType, connections

# 1. Conecta ao Milvus
connections.connect(host='localhost', port='19530')

# 2. Define schema
fields = [
    FieldSchema(name="id", dtype=DataType.INT64, is_primary=True),
    FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=384),
    FieldSchema(name="texto", dtype=DataType.VARCHAR, max_length=1000)
]
schema = CollectionSchema(fields, "Coleção de embeddings")

# 3. Cria coleção
collection = Collection("documentos_rag", schema)

# 4. Cria índice
index_params = {
    "metric_type": "IP",  # Inner Product (similar ao cosseno)
    "index_type": "HNSW",
    "params": {"M": 16, "efConstruction": 200}
}
collection.create_index("embedding", index_params)

# 5. Insere dados
import random
embeddings_random = np.random.random((100, 384)).tolist()
textos_random = [f"Documento {i}" for i in range(100)]

collection.insert([
    [i for i in range(100)],  # ids
    embeddings_random,        # embeddings
    textos_random             # textos
])

# 6. Busca
search_params = {"metric_type": "IP", "params": {"ef": 64"}}
result = collection.search(
    [query_embedding.tolist()],
    "embedding",
    search_params,
    limit=5,
    output_fields=["texto"]
)

for hits in result:
    for hit in hits:
        print(f"Score: {hit.score:.4f} - Texto: {hit.entity.get('texto')}")

6.6 Otimizações para Busca em Larga Escala

1. Quantização de Vetores
Python
# Reduz precisão para economizar memória
from sklearn.preprocessing import QuantileTransformer

def quantizar_embeddings(embeddings, n_bits=8):
    """Quantiza embeddings para n_bits de precisão."""
    transformer = QuantileTransformer(n_quantiles=2**n_bits)
    quantizados = transformer.fit_transform(embeddings)
    return quantizados.astype(np.uint8)
2. Cache de Resultados Frequentes
Python
from functools import lru_cache

@lru_cache(maxsize=1000)
def buscar_query_cache(query_text, k=5):
    """Cache para queries frequentes."""
    embedding = modelo.encode(query_text)
    _, indices = indice.search(embedding.reshape(1, -1), k)
    return indices.tolist()

# Primeira busca (lenta)
resultados = buscar_query_cache("O que é machine learning?")

# Busca seguinte (rápida - do cache)
resultados = buscar_query_cache("O que é machine learning?")
3. Sharding (Particionamento)

Para bases > 100 milhões de documentos, distribua o índice em múltiplos servidores.

Python
# Pseudocódigo para sharding
class ShardedVectorIndex:
    def __init__(self, num_shards):
        self.shards = [create_index() for _ in range(num_shards)]
        self.num_shards = num_shards
    
    def add(self, vectors, ids):
        for vec, id in zip(vectors, ids):
            shard_id = hash(id) % self.num_shards
            self.shards[shard_id].add(vec)
    
    def search(self, query, k):
        results = []
        for shard in self.shards:
            results.extend(shard.search(query, k))
        return sorted(results, key=lambda x: x.score)[:k]

7. Melhores Práticas

7.1 Chunking Inteligente

O chunking (divisão de documentos) é crítico para a qualidade do RAG.

Boas Práticas:
  • Tamanho Ideal: 200-500 tokens (cerca de 1 parágrafo)
  • Overlap: 10-20% para manter contexto entre chunks
  • Quebras Semânticas: Use pontuação e parágrafos como separadores
  • Metadados: Inclua título, fonte, data no chunk
Python
from langchain.text_splitter import (
    RecursiveCharacterTextSplitter,
    MarkdownHeaderTextSplitter,
    PythonCodeTextSplitter
)

# Para documentos em Markdown
markdown_splitter = MarkdownHeaderTextSplitter(
    headers_to_split_on=[
        ("#", "Header 1"),
        ("##", "Header 2"),
        ("###", "Header 3"),
    ]
)
chunks_markdown = markdown_splitter.split_text(documento_markdown)

# Para código Python
python_splitter = PythonCodeTextSplitter(chunk_size=200, chunk_overlap=20)
chunks_python = python_splitter.split_text(codigo_python)

# Chunking avançado com análise semântica
from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased")

def chunk_semantico(texto, max_tokens=500, overlap=50):
    """Divide texto em chunks baseados em frases e parágrafos."""
    sentences = texto.split('. ')
    chunks = []
    current_chunk = []
    current_length = 0
    
    for sent in sentences:
        sent_tokens = len(tokenizer.tokenize(sent))
        
        if current_length + sent_tokens > max_tokens and current_chunk:
            # Finaliza chunk atual
            chunks.append('. '.join(current_chunk))
            
            # Overlap: mantém últimas frases
            overlap_tokens = 0
            overlap_chunk = []
            for s in reversed(current_chunk):
                s_tokens = len(tokenizer.tokenize(s))
                if overlap_tokens + s_tokens <= overlap:
                    overlap_chunk.insert(0, s)
                    overlap_tokens += s_tokens
                else:
                    break
            current_chunk = overlap_chunk
            current_length = overlap_tokens
        
        current_chunk.append(sent)
        current_length += sent_tokens
    
    if current_chunk:
        chunks.append('. '.join(current_chunk))
    
    return chunks

7.2 Escolha do Modelo Baseada no Domínio

Python
# Mapeamento de modelo por domínio
MODELOS_POR_DOMINIO = {
    'geral': 'all-MiniLM-L6-v2',
    'alta_precisao': 'all-mpnet-base-v2',
    'multilingue': 'paraphrase-multilingual-MiniLM-L12-v2',
    'medicina': 'microsoft/BiomedNLP-PubMedBERT-base-uncased-abstract-fulltext',
    'juridico': 'nlpaueb/legal-bert-base-uncased',
    'codigo': 'microsoft/codebert-base',
    'financas': 'ProsusAI/finbert',
}

def escolher_modelo(dominio):
    """Retorna o modelo recomendado para um domínio."""
    return SentenceTransformer(MODELOS_POR_DOMINIO.get(dominio, 'all-MiniLM-L6-v2'))

# Exemplo
modelo_medicina = escolher_modelo('medicina')

7.3 Normalização de Embeddings

A normalização garante que a magnitude não influencie a similaridade.

Python
import numpy as np

def normalizar_batch(embeddings):
    """Normaliza um batch de embeddings (L2 norm)."""
    norms = np.linalg.norm(embeddings, axis=1, keepdims=True)
    return embeddings / (norms + 1e-8)  # Evita divisão por zero

# Antes da indexação
embeddings_normalizados = normalizar_batch(embeddings_chunks)

# Verifica
print(f"Norma antes: {np.linalg.norm(embeddings_chunks[0]):.4f}")
print(f"Norma depois: {np.linalg.norm(embeddings_normalizados[0]):.4f}")

# Para FAISS com similaridade por cosseno
if 'IndexFlatIP' in str(type(indice)):  # Inner Product
    embeddings_para_index = embeddings_normalizados
else:
    embeddings_para_index = embeddings_chunks

7.4 Avaliação de Recuperação

Métricas essenciais para medir a qualidade da recuperação:

Python
from sklearn.metrics import ndcg_score
import numpy as np

def avaliar_recuperacao_completa(
    queries, 
    documentos_relevantes, 
    funcao_busca, 
    k_values=[1, 3, 5, 10]
):
    """Avaliação abrangente da recuperação."""
    resultados = {}
    
    for k in k_values:
        precision_sum = 0
        recall_sum = 0
        ndcg_sum = 0
        
        for query, relevantes in zip(queries, documentos_relevantes):
            # Recupera top-k
            docs_recuperados = funcao_busca(query, k)
            
            # Precision@K
            relevantes_recuperados = sum(1 for doc in docs_recuperados if doc in relevantes)
            precision = relevantes_recuperados / k
            precision_sum += precision
            
            # Recall@K
            recall = relevantes_recuperados / len(relevantes)
            recall_sum += recall
            
            # NDCG@K
            # Cria relevância binária (1 se relevante, 0 senão)
            relevancia_recuperados = [1 if doc in relevantes else 0 for doc in docs_recuperados]
            if len(relevancia_recuperados) < k:
                relevancia_recuperados += [0] * (k - len(relevancia_recuperados))
            
            # NDCG requer relevância ideal
            relevancia_ideal = [1] * min(len(relevantes), k) + [0] * max(0, k - len(relevantes))
            ndcg = ndcg_score([relevancia_ideal], [relevancia_recuperados])
            ndcg_sum += ndcg
        
        resultados[f'Precision@{k}'] = precision_sum / len(queries)
        resultados[f'Recall@{k}'] = recall_sum / len(queries)
        resultados[f'NDCG@{k}'] = ndcg_sum / len(queries)
    
    return resultados

# Exemplo de uso
def busca_exemplo(query, k):
    # Simula busca (substituir pela real)
    return [f"Doc {i}" for i in range(k)]

resultados_avaliacao = avaliar_recuperacao_completa(
    queries=["O que é RAG?", "Explique embeddings"],
    documentos_relevantes=[["Doc 1", "Doc 2"], ["Doc 0", "Doc 3"]],
    funcao_busca=busca_exemplo
)

for metrica, valor in resultados_avaliacao.items():
    print(f"{metrica}: {valor:.3f}")

7.5 Atualização do Índice

Estratégias para manter o índice atualizado:

Python
class IndexAtualizavel:
    """Índice com suporte a atualizações incrementais."""
    
    def __init__(self, modelo, dimensao):
        self.modelo = modelo
        self.dimensao = dimensao
        self.indice = faiss.IndexFlatIP(dimensao)
        self.documentos = []
        self.embeddings = []
    
    def adicionar_documentos(self, novos_documentos):
        """Adiciona novos documentos ao índice."""
        novos_embeddings = self.modelo.encode(novos_documentos)
        novos_embeddings_normalizados = normalizar_batch(novos_embeddings)
        
        # Adiciona ao índice
        self.indice.add(novos_embeddings_normalizados.astype('float32'))
        
        # Armazena documentos
        self.documentos.extend(novos_documentos)
        self.embeddings.append(novos_embeddings)
        
        print(f"Índice atualizado: {self.indice.ntotal} documentos")
    
    def remover_documentos(self, indices_remover):
        """Remove documentos (reconstroi índice)."""
        # Mantém apenas documentos não removidos
        manter_indices = [i for i in range(len(self.documentos)) if i not in indices_remover]
        
        self.documentos = [self.documentos[i] for i in manter_indices]
        embeddings_manter = [self.embeddings[i] for i in manter_indices]
        
        # Reconstrói índice
        self.indice = faiss.IndexFlatIP(self.dimensao)
        if embeddings_manter:
            embeddings_array = np.vstack(embeddings_manter)
            embeddings_normalizados = normalizar_batch(embeddings_array)
            self.indice.add(embeddings_normalizados.astype('float32'))
    
    def buscar(self, query, k=5):
        """Busca documentos similares."""
        emb_query = self.modelo.encode([query])
        emb_query_normalizado = normalizar_batch(emb_query)
        
        distancias, indices = self.indice.search(
            emb_query_normalizado.astype('float32'), 
            k
        )
        
        return [(self.documentos[idx], dist) for idx, dist in zip(indices[0], distancias[0])]

# Uso
index_atualizavel = IndexAtualizavel(modelo, 384)
index_atualizavel.adicionar_documentos(["Doc1", "Doc2", "Doc3"])
index_atualizavel.adicionar_documentos(["Doc4", "Doc5"])

# Busca
resultados = index_atualizavel.buscar("Pergunta de exemplo", k=3)

7.6 Monitoramento e Logging

Python
import logging
from datetime import datetime
import json

class RAGMonitor:
    """Monitora performance do sistema RAG."""
    
    def __init__(self, log_file='rag_logs.json'):
        self.log_file = log_file
        self.logs = []
    
    def log_query(self, query, num_resultados, tempo_busca):
        """Registra uma consulta."""
        log_entry = {
            'timestamp': datetime.now().isoformat(),
            'query': query[:100],  # Trunca
            'num_resultados': num_resultados,
            'tempo_busca_ms': tempo_busca * 1000
        }
        self.logs.append(log_entry)
        
        # Salva em arquivo
        with open(self.log_file, 'a') as f:
            f.write(json.dumps(log_entry) + '\n')
    
    def relatorio(self):
        """Gera relatório de estatísticas."""
        if not self.logs:
            return "Sem logs"
        
        tempos = [log['tempo_busca_ms'] for log in self.logs]
        
        return {
            'total_queries': len(self.logs),
            'tempo_medio_ms': sum(tempos) / len(tempos),
            'tempo_max_ms': max(tempos),
            'tempo_min_ms': min(tempos),
            'queries_ultima_hora': sum(
                1 for log in self.logs 
                if (datetime.now() - datetime.fromisoformat(log['timestamp'])).seconds < 3600
            )
        }

# Uso
monitor = RAGMonitor()
monitor.log_query("O que é RAG?", 5, 0.032)
monitor.log_query("Explique embeddings", 3, 0.028)
print(monitor.relatorio())

8. Conclusão e Próximos Passos

8.1 Resumo dos Conceitos

Neste artigo aprofundado, exploramos:

  • Embeddings: Representações vetoriais densas que capturam significado semântico.
  • Funcionamento: Modelos transformers mapeiam texto para espaço vetorial de alta dimensão.
  • RAG: Pipeline que combina recuperação vetorial com geração por LLM.
  • Modelos: Comparação detalhada entre opções open-source e comerciais.
  • Busca: Técnicas ANN (FAISS, HNSW, IVF) para escalabilidade.
  • Melhores Práticas: Chunking, normalização, avaliação e atualização.

8.2 Arquitetura Final Recomendada

Arquitetura de um pipeline RAG (Retrieval-Augmented Generation), ilustrando o fluxo desde a indexação de documentos até a geração de respostas com contexto, incluindo otimizações como cache, monitoramento e re-ranking
Arquitetura de um pipeline RAG (Retrieval-Augmented Generation), ilustrando o fluxo desde a indexação de documentos até a geração de respostas com contexto, incluindo otimizações como cache, monitoramento e re-ranking

8.3 Próximos Passos para Produção

  1. Pipeline de Dados:

    • Automatize a ingestão e atualização de documentos
    • Implemente versionamento de embeddings
  2. Otimização de Custo:

    • Use batch processing para embeddings
    • Implemente caching de queries frequentes
    • Considere modelos menores para prototipagem
  3. Segurança e Privacidade:

    • Criptografe dados sensíveis
    • Implemente controle de acesso baseado em metadados
    • Anonimize embeddings quando necessário
  4. Escalabilidade:

    • Distribua índices em múltiplos servidores
    • Use sharding para bilhões de documentos
    • Implemente replicação para alta disponibilidade
  5. Avaliação Contínua:

    • Monitore precisão da recuperação
    • Coleta feedback dos usuários
    • A/B teste diferentes modelos e parâmetros

8.4 Recursos e Ferramentas

FerramentaUsoLink
Sentence-TransformersEmbeddings open-sourcesbert.net
FAISSBusca vetorial eficientegithub.com/facebookresearch/faiss
MilvusBanco vetorial distribuídomilvus.io
LangChainOrquestração RAGlangchain.com
HuggingFaceModelos e datasetshuggingface.co
WeaviateBanco vetorial com GraphQLweaviate.io
PineconeBanco vetorial gerenciadopinecone.io

8.5 Exemplo de Projeto Completo

Para consolidar todo o conhecimento, aqui está um esqueleto de um projeto RAG completo:

Python
# rag_pipeline.py

import os
from typing import List, Dict, Any
from sentence_transformers import SentenceTransformer
import faiss
import numpy as np
from langchain.text_splitter import RecursiveCharacterTextSplitter

class SistemaRAG:
    def __init__(
        self, 
        modelo_nome: str = 'all-MiniLM-L6-v2',
        chunk_size: int = 500,
        chunk_overlap: int = 50,
        top_k: int = 5
    ):
        self.modelo = SentenceTransformer(modelo_nome)
        self.chunk_size = chunk_size
        self.chunk_overlap = chunk_overlap
        self.top_k = top_k
        self.indice = None
        self.chunks = []
        self.metadados = []
        
    def preparar_documentos(self, documentos: List[Dict[str, str]]):
        """Prepara e indexa documentos."""
        # Divide em chunks
        splitter = RecursiveCharacterTextSplitter(
            chunk_size=self.chunk_size,
            chunk_overlap=self.chunk_overlap
        )
        
        for doc in documentos:
            chunks = splitter.split_text(doc['texto'])
            for chunk in chunks:
                self.chunks.append(chunk)
                self.metadados.append({
                    'fonte': doc.get('fonte', 'Desconhecido'),
                    'data': doc.get('data', ''),
                    'titulo': doc.get('titulo', '')
                })
        
        # Gera embeddings e indexa
        embeddings = self.modelo.encode(self.chunks)
        embeddings_normalizados = embeddings / np.linalg.norm(embeddings, axis=1, keepdims=True)
        
        self.indice = faiss.IndexFlatIP(embeddings.shape[1])
        self.indice.add(embeddings_normalizados.astype('float32'))
        
        print(f"✓ {len(self.chunks)} chunks indexados")
    
    def consultar(self, query: str) -> Dict[str, Any]:
        """Executa uma consulta e retorna contexto e resposta."""
        # Embedding da query
        emb_query = self.modelo.encode([query])
        emb_query_normalizado = emb_query / np.linalg.norm(emb_query)
        
        # Busca
        scores, indices = self.indice.search(
            emb_query_normalizado.astype('float32'), 
            self.top_k
        )
        
        # Recupera contexto
        contextos = []
        for idx, score in zip(indices[0], scores[0]):
            if idx != -1:
                contextos.append({
                    'texto': self.chunks[idx],
                    'score': float(score),
                    'metadados': self.metadados[idx]
                })
        
        # Prepara para LLM (aqui você integraria com GPT, LLaMA, etc.)
        contexto_texto = "\n\n".join([c['texto'] for c in contextos])
        
        return {
            'query': query,
            'contextos': contextos,
            'contexto_completo': contexto_texto,
            'num_chunks_recuperados': len(contextos)
        }

# Uso
if __name__ == "__main__":
    # Inicializa sistema
    rag = SistemaRAG()
    
    # Carrega documentos
    docs = [
        {'texto': 'Aprendizado de máquina é uma subárea da IA...', 'fonte': 'Wiki'},
        {'texto': 'Redes neurais são inspiradas no cérebro...', 'fonte': 'Livro'}
    ]
    rag.preparar_documentos(docs)
    
    # Consulta
    resultado = rag.consultar("O que é aprendizado de máquina?")
    print(f"Query: {resultado['query']}")
    print(f"Contextos recuperados: {len(resultado['contextos'])}")
    for ctx in resultado['contextos']:
        print(f"- {ctx['texto'][:100]}... (score: {ctx['score']:.3f})")

Considerações Finais

Os embeddings são uma tecnologia fundamental que está no coração dos sistemas RAG modernos. Dominar seus conceitos, desde a teoria básica até as otimizações de produção, é essencial para construir aplicações de IA que sejam precisas, escaláveis e confiáveis.

  • À medida que o campo evolui, podemos esperar:
  • Modelos de embedding ainda mais eficientes e precisos
  • Técnicas híbridas que combinam múltiplas abordagens
  • Integração mais profunda com sistemas de memória de longo prazo
  • Embeddings multimodais para texto, imagem e áudio

O futuro da IA é multimodal, contextual e aumentada por recuperação — e os embeddings são a ponte que torna isso possível.

Exemplo de Projeto completo: Recomendador de Filmes com OllamaAPI + RAG + Chunking + Embeddings + PostgreSQL/pgvector

Fique ligado

Seja um Expert em Growth

Receba insights práticos sobre marketing, dados, performance e tecnologia direto no seu email.