Voltar ao início

AI Internals - Embeddings e Retrieval

13 min de leitura
Cover Image for AI Internals - Embeddings e Retrieval
Lucas LemosLucas Lemos

Introduction

Em AI Internals - Prompts & Context tratamos retrieval como um slot no pipeline de montagem: injetar snippets curtos e relevantes em vez de documentos inteiros.

Esta parte é sobre como esses snippets são escolhidos. Um modelo de embedding mapeia texto para um vetor de tamanho fixo. Uma stack de retrieval guarda esses vetores, compara o vetor da query com eles e devolve os vizinhos mais próximos. Esse problema de busca é separado da geração — e a maior parte dos bugs de "RAG está quebrado" são bugs de retrieval que só aparecem depois que o LLM inventa em volta de evidência que faltou.

Aqui ficamos na busca: vetores, similaridade, índices, retrieval híbrido e reranking. Estratégias de chunking e grounding da resposta no texto recuperado ficam para RAG.

Dois "embeddings" diferentes

Em Como LLMs Funcionam, "embedding" era a tabela de entrada do modelo: token ID → vetor dentro do residual stream do transformer. Essa tabela faz parte do next-token prediction.

Aqui o artefato é outro: um embedding de sentença ou passagem produzido por um modelo treinado (ou adaptado) para que textos com significado relacionado fiquem perto no espaço vetorial. Você chama uma API de embeddings ou roda um encoder local, recebe algo como 384 / 768 / 1536 floats, e nunca amostra uma completion a partir disso.

Misturar os dois leva a escolhas estranhas de produto — por exemplo esperar que chat completions de gpt-4.1-mini te deem um índice reutilizável de documentos, ou tratar distância de cosseno como "o que o LLM pensa".

O que o vetor está codificando

Um modelo de embedding ainda é uma rede neural, mas o objetivo de treino empurra textos relacionados para perto e textos não relacionados para longe (losses contrastivas, hard negatives, pares de retrieval com instrução — os detalhes variam por família de modelo).

Consequências operacionais:

  • O mesmo modelo para índice e query. Misturar vetores de text-embedding-3-small com um índice aberto nomic é ruído com UI bonita. Reconstrua o índice quando trocar o modelo de embedding.
  • Dimensão é contrato. Um índice 768-d não guarda vetores 1536-d. Provedores às vezes expõem um parâmetro dimensions que trunca ou projeta — só use se a documentação disser que os vetores encurtados continuam comparáveis.
  • Normalização importa para a métrica. Muitas APIs devolvem vetores L2-normalizados para cosine virar dot product. Se você normaliza duas vezes, ou pula normalização assumindo cosine, o ranking muda em silêncio.

Proximidade é geométrica, não mágica:

query:  "refund policy for annual plans"
near:   "How do I cancel and get money back on a yearly subscription?"
far:    "How do I rotate API keys?"

Overlap lexical ajuda alguns modelos e métricas, mas o ponto do dense retrieval é pegar paráfrase. Quando falha, costuma falhar em queries curtas, jargão de domínio que o embedder nunca viu, ou passagens que respondem a pergunta sem compartilhar palavras de superfície do jeito que o modelo foi treinado.

Similaridade: cosine, dot product, L2

Dados dois vetores a e b:

MétricaIdeiaUso típico
CosineÂngulo entre vetores (direção)Default de muitas APIs de embedding de texto
Dot productIgual a cosine se ambos têm norma unitáriaCaminho rápido com vetores pré-normalizados
L2 / EuclidianaDistância em linha retaAlguns índices e embeddings de visão

Escolha uma métrica e mantenha consistente das premissas de treino até a configuração do índice. A maioria dos bancos vetoriais gerenciados pede a função de distância na criação da collection; mudar depois significa reindexar.

Scores só são comparáveis dentro da mesma query. Um cosine de 0.72 num corpus não significa a mesma coisa que 0.72 noutro modelo ou noutra collection. Thresholds precisam de calibração nos seus dados, não de defaults de blog.

O pipeline de retrieval

Duas fases, dois relógios:

Index time (offline ou async):

  1. Dividir docs fonte em unidades de retrieval (passagens). Política de chunking pertence ao RAG; neste artigo trate cada unidade como uma string com ID e metadata.
  2. Embeddar cada unidade com o modelo escolhido.
  3. Fazer upsert de (id, vector, metadata, ponteiro do texto) no store.

Query time (por request):

  1. Embeddar a query (às vezes com um prefixo de instrução específico de query que o modelo espera).
  2. Buscar top-k vizinhos mais próximos, opcionalmente filtrados por metadata.
  3. Opcionalmente reranquear ou fundir com hits lexicais.
  4. Entregar os snippets sobreviventes ao assembler de prompt do artigo anterior.

Orçamentos de latência costumam morrer na chamada de embedding mais o round-trip do índice. Cachear embeddings de query só ajuda quando a mesma string se repete; search de produto e bots de suporte muitas vezes não.

Busca exata vs índices aproximados

Força bruta: calcular distância para cada vetor, ordenar, pegar top-k. Correto, e ok para dezenas de milhares de vetores em memória. Doloroso em milhões com p99 apertado.

Índices de approximate nearest neighbor (ANN) trocam um pouco de recall por velocidade. HNSW (Hierarchical Navigable Small World) é a estrutura em grafo que você mais vê em pgvector, Qdrant, Weaviate e afins: grafos de proximidade em camadas para a busca caminhar em direção à vizinhança da query em vez de varrer tudo. Outras famílias (IVF, product quantization) apertam memória e disco com curvas de recall diferentes.

O que importa como autor de app:

  • ANN é aproximado. Subir ef_search / número de probes em geral melhora recall e custa latência.
  • Um índice mal construído (métrica errada, grafo subdimensionado) parece "o modelo é burro" lá na frente.
  • Comece exato ou com settings de alto recall enquanto o corpus é pequeno. Otimize quando as medições disserem que a busca é o gargalo.

Filtros de metadata (tenant_id, product, lang) não são decoração opcional. Eles cortam o conjunto candidato antes ou durante a busca para você não recuperar docs de outro cliente. A semântica de filtro difere por engine — algumas filtram e depois buscam, outras buscam e depois filtram — e isso muda o recall quando os filtros são seletivos.

Abaixo está o formato de uma chamada de API de embeddings mais top-k em memória por cosine. Código de produção usa um índice de verdade; a aritmética é a mesma.

package main

import (
  "bytes"
  "encoding/json"
  "fmt"
  "io"
  "math"
  "net/http"
  "os"
  "sort"
)

func embed(text string) ([]float64, error) {
  body, _ := json.Marshal(map[string]any{
    "model": "text-embedding-3-small",
    "input": text,
  })
  req, _ := http.NewRequest(
    "POST",
    "https://api.openai.com/v1/embeddings",
    bytes.NewReader(body),
  )
  req.Header.Set("Authorization", "Bearer "+os.Getenv("OPENAI_API_KEY"))
  req.Header.Set("Content-Type", "application/json")

  res, err := http.DefaultClient.Do(req)
  if err != nil {
    return nil, err
  }
  defer res.Body.Close()
  raw, _ := io.ReadAll(res.Body)

  var parsed map[string]any
  if err := json.Unmarshal(raw, &parsed); err != nil {
    return nil, err
  }
  first := parsed["data"].([]any)[0].(map[string]any)
  rawVec := first["embedding"].([]any)
  out := make([]float64, len(rawVec))
  for i, v := range rawVec {
    out[i] = v.(float64)
  }
  return out, nil
}

func cosine(a, b []float64) float64 {
  var dot, na, nb float64
  for i := range a {
    dot += a[i] * b[i]
    na += a[i] * a[i]
    nb += b[i] * b[i]
  }
  return dot / (math.Sqrt(na) * math.Sqrt(nb))
}

func main() {
  passages := []string{
    "Annual plans can be refunded within 14 days of purchase.",
    "API keys are rotated from the developer settings page.",
    "Incident severity is assigned by customer impact.",
  }

  type row struct {
    text  string
    score float64
  }

  q, err := embed("How do I get money back on a yearly subscription?")
  if err != nil {
    panic(err)
  }

  var ranked []row
  for _, p := range passages {
    v, err := embed(p)
    if err != nil {
      panic(err)
    }
    ranked = append(ranked, row{text: p, score: cosine(q, v)})
  }
  sort.Slice(ranked, func(i, j int) bool {
    return ranked[i].score > ranked[j].score
  })

  for _, r := range ranked {
    fmt.Printf("%.3f %s\n", r.score, r.text)
  }
}

Rode uma vez e a passagem de refund vence. As falhas interessantes aparecem quando você adiciona passagens quase certas, muda a query para um ID de produto, ou embedda com um modelo diferente do que construiu um índice persistido.

Dense, sparse e hybrid

Dense retrieval (vetores) é forte em paráfrase e fraco em tokens exatos: códigos de erro, SKUs, nomes próprios raros, números de cláusula legal. Sparse / lexical retrieval (BM25, índices invertidos estilo Lucene) é o oposto: ótimo quando a resposta certa compartilha keywords raras, fraco quando o usuário parafraseia.

Hybrid search roda os dois e funde as listas ranqueadas — reciprocal rank fusion (RRF) é um merge comum e leve em parâmetros: pontua por posição em cada lista, não por magnitudes brutas de similaridade.

Se o corpus está cheio de identificadores e strings de erro entre aspas, vector search puro vai continuar te envergonhando. Se toda pergunta é paráfrase de prosa de política, BM25 sozinho vai errar. Meça os dois antes de declarar vencedor.

Reranking como segundo estágio

Top-k de um índice ANN é gerador de candidatos, não julgamento final. Um cross-encoder reranker (ou API de rerank do provedor) pontua pares (query, passage) em conjunto e reordena a shortlist. É mais lento por par do que um lookup vetorial, então você só roda em 20–100 candidatos, não no corpus inteiro.

Padrão típico:

  1. Recuperar 50 com hybrid / dense.
  2. Reranquear para 5–10.
  3. Empacotar esses no prompt sob o orçamento de tokens de Prompts & Context.

Reranking resolve muita dor de "chunk quase certo na posição 7". Não resolve índice vazio, filtro de tenant errado, ou modelo de embedding que não representa o seu domínio.

Modos de falha na camada de retrieval

Depure aqui antes de culpar o modelo de chat:

  • Vizinhos errados, resposta fluente: retrieval devolveu snippets irrelevantes; o LLM preencheu buracos. Logue id, score e texto de cada chunk injetado.
  • Doc certo nunca aparece: drift no embedding da query (prefixo de instrução faltando), recall de ANN baixo demais, ou filtros excluindo a linha.
  • IDs e códigos de erro falham: stack só dense; adicione sparse / hybrid.
  • Scores "bons" mas usuários odeiam resultados: thresholds sem calibração; avalie recall@k num conjunto de queries rotulado em vez de olhar cosine no olho.
  • Índice stale depois de updates de docs: pipeline de upsert/delete quebrado; a geração parece atual porque o modelo ainda "sabe" fluff antigo de treino.
  • Modelos de embedding misturados numa collection: lixo silencioso. Versione o nome do modelo junto do índice.

Hábito barato: para cada resposta ruim, imprima o conjunto recuperado com scores. Se a evidência nunca esteve lá, nenhum rewrite de prompt te salva.

Comparação: o que alcançar

AbordagemForçasFraquezasUse quando
Só denseParáfrase; pipeline simplesFraco em tokens raros; precisa de bom embedderCorpora de política / prosa, perguntas em linguagem natural
Só sparseKeywords exatas, barato, explicávelPerde paráfraseLogs, SKUs, strings de erro, citações legais
Hybrid + RRFCobre os dois modos de falhaMais peças móveisA maioria das knowledge bases de produção
+ RerankMelhor ordenação de uma shortlistLatência e custo extrasRespostas sensíveis à qualidade com contexto final pequeno

Sistemas sérios costumam cair em retrieval híbrido mais um passo de rerank quando qualidade importa mais que o demo.

Conclusão

Retrieval é um sistema de busca na frente do assembler de prompt: embeddar passagens, indexar, embeddar a query, buscar vizinhos, opcionalmente fundir e reranquear. O modelo generativo só vê o que você injeta. Mantenha modelo de embedding, métrica e versão de índice alinhados, e trate scores top-k como candidatos até algo medir recall nas suas queries.

No próximo vamos para RAG — chunking dessas passagens, grounding da geração no que foi recuperado, e os modos de falha que só aparecem depois que busca e geração estão ligados.