A ideia central: um embedding é um vetor de números reais que representa um pedaço de texto. Ele não guarda as palavras: guarda uma posição num espaço. Textos com sentido parecido caem em posições próximas, e "próximo" vira uma conta que o banco de dados sabe fazer em milissegundos.
O que embeddings NÃO são
não são compressão: você não volta ao texto a partir do vetor
não são hash: textos parecidos dão vetores parecidos, de propósito
não medem verdade: só proximidade de sentido no espaço aprendido
não são comparáveis entre modelos: cada modelo tem seu próprio espaço
Onde isso aparece no seu sistema
busca semântica: achar o documento certo sem casar palavra
RAG: injetar o trecho certo no prompt do LLM
deduplicação e clustering de conteúdo
classificação barata: k-NN sobre vetores, sem fine-tuning
Explore cada etapa →
A matemática, num só lugar
As 4 fórmulas que sustentam tudo. Clique em qualquer uma para pular direto para a etapa que a explica.
De onde veio a ideia
1954 → 2013 → hoje
Em 1954, o linguista Zellig Harris formulou a hipótese distribucional: "palavras que ocorrem nos mesmos contextos tendem a ter significados parecidos". Em 2013, o word2vec (Mikolov et al., Google) transformou isso em vetores treináveis e mostrou que a geometria carregava semântica. Hoje os embeddings vêm de Transformers: o mesmo princípio, só que o vetor de uma palavra passou a depender da frase inteira em volta dela.
Cuidado são dois embeddings diferentes. O embedding de token vive dentro do LLM: é uma linha de uma matriz de lookup, um vetor por token do vocabulário. O embedding de sentença/documento é o que uma API de embedding devolve: um único vetor para o texto inteiro, produzido depois de o texto passar pela rede. Confundir os dois é a origem de metade dos bugs em pipelines de RAG.
1 · Embedding de token uma linha de tabela
O modelo guarda uma matriz E de formato [vocabulário × d_model] tipicamente algo como [100.000 × 4.096]. Pegar o embedding do token de ID i é literalmente ler a linha i. A multiplicação por vetor one-hot é a formalização; na prática é um índice de array.
matriz E · [vocab × d_model]
→
Linha selecionada token gato, ID #0000:
esses números são pesos aprendidos, e mudam durante o treino
2 · Embedding de texto pooling + normalização
O texto inteiro passa pela rede e sai uma matriz [n_tokens × d]: um vetor contextual por token. Para virar um vetor, aplica-se pooling. Depois, quase sempre, normalização L2 que é o que permite trocar cosseno por produto escalar mais adiante.
Mean pooling
Média dos vetores de todos os tokens (com máscara de padding). Padrão em modelos tipo sentence-transformers.
Token [CLS]
Usa o vetor de um token especial treinado para resumir a sequência. Comum em modelos família BERT.
Último token
Em modelos decoder-only, só o último token "viu" a frase toda então ele vira o resumo.
Fórmula · normalização L2
v̂ = v / ‖v‖₂ onde ‖v‖₂ = √(Σivi²)
Dividir o vetor pelo próprio comprimento joga todo mundo na superfície de uma esfera de raio 1. O tamanho do vetor some; só a direção sobrevive e é a direção que carrega o significado. Com vetores normalizados, cosseno = produto escalar, o que economiza duas raízes quadradas por comparação. É por isso que praticamente todo índice vetorial guarda vetores já normalizados.
pooling + L2, na mão
import numpy as np
from transformers import AutoTokenizer, AutoModel
tok = AutoTokenizer.from_pretrained("sentence-transformers/all-MiniLM-L6-v2")
mdl = AutoModel.from_pretrained("sentence-transformers/all-MiniLM-L6-v2")
batch = tok(["o gato preto subiu no telhado"], padding=True, return_tensors="pt")
out = mdl(**batch).last_hidden_state # [1, n_tokens, 384]# mean pooling COM máscara somar padding envenena o vetor
mask = batch["attention_mask"].unsqueeze(-1).float()
vec = (out * mask).sum(1) / mask.sum(1).clamp(min=1e-9)
# normalização L2 → norma 1, cosseno vira produto escalar
vec = vec / vec.norm(dim=1, keepdim=True)
v = vec[0].detach().numpy()
print(v.shape, np.linalg.norm(v)) # (384,) 1.0
// dada a matriz [n_tokens x d] devolvida pelo modeloconstmeanPool = (tokens, mask) => {
const d = tokens[0].length;
const acc = newFloat32Array(d);
let n = 0;
tokens.forEach((t, i) => {
if (!mask[i]) return; // ignora paddingfor (let k = 0; k < d; k++) acc[k] += t[k];
n++;
});
return Array.from(acc, (x) => x / (n || 1));
};
constl2 = (v) => {
const norm = Math.hypot(...v) || 1;
return v.map((x) => x / norm); // ‖v̂‖ = 1
};
Armadilha de produção: vários modelos exigem prefixos assimétricos algo como query: … para a pergunta e passage: … para o documento. Indexar sem o prefixo e consultar com ele (ou vice-versa) degrada a busca em silêncio: nada quebra, os resultados só ficam piores. Leia a model card antes de indexar um milhão de documentos.
Cada posição do vetor é uma dimensão. Em modelos reais são centenas ou milhares delas, e nenhuma tem nome o treino distribui o significado por combinações de eixos que ninguém rotulou. Aqui usamos um espaço-brinquedo de 8 dimensões nomeadas, só para você conseguir ver o que os números fazem. Clique numa palavra:
Vetor de · 8 dimensões
Usando 8 de 8 dimensões
arraste para cortar o vetor
1 dim: quase nada sobrevive8 dims: espaço completo
Vizinhos de usando só as primeiras dimensões (o vetor é truncado e renormalizado):
Por que truncar funciona (às vezes)
Cortar um vetor comum no meio destrói informação de forma imprevisível: nada garante que as primeiras dimensões sejam as mais importantes. O Matryoshka Representation Learning (MRL) muda o treino para que sejam: o modelo é otimizado para que qualquer prefixo do vetor já seja um embedding utilizável. É o que permite APIs exporem um parâmetro dimensions e cortarem 1536 → 256 sem retreinar nada.
O custo de cada dimensão
Para 1 milhão de vetores em float32. Some o índice HNSW por cima (na prática, +30% a +60%). Quantizar para int8 corta a memória por 4 com perda de recall geralmente na casa de 1–3%.
truncar com honestidade
import numpy as np
defshrink(v: np.ndarray, k: int) -> np.ndarray:
"""Trunca para k dimensões e RENORMALIZA.
Sem renormalizar, a norma cai junto com as dimensões e
o produto escalar deixa de ser cosseno as distâncias
ficam silenciosamente erradas.
"""
head = v[:k]
return head / (np.linalg.norm(head) + 1e-12)
# só faz sentido se o modelo foi treinado com MRL.# num modelo comum, prefira PCA ajustado no SEU corpus:from sklearn.decomposition import PCA
pca = PCA(n_components=256).fit(train_vectors)
small = pca.transform(all_vectors)
-- pgvector: a dimensão faz parte do tipo da colunaCREATE TABLE chunks (
id bigserial PRIMARY KEY,
doc_id bigint NOT NULL,
content text NOT NULL,
embedding vector(384) -- fixa: mudar exige migração
);
-- half-precision corta a memória do índice pela metadeCREATE INDEXON chunks
USING hnsw ((embedding::halfvec(384)) halfvec_cosine_ops);
Regra prática: dimensão maior não é automaticamente melhor. Meça recall@k no seu conjunto de perguntas reais, não no benchmark do vendor. É comum um modelo de 384d bem escolhido bater um de 1536d genérico no seu domínio por um quarto do custo de armazenamento.
Para comparar dois textos você compara dois vetores. A medida padrão é a semelhança do cosseno: o cosseno do ângulo entre eles. Ela ignora o tamanho dos vetores e olha só a direção que é exatamente o que queremos, já que documento longo e documento curto sobre o mesmo assunto devem ficar próximos. Escolha duas palavras:
O produto escalar soma o produto das dimensões par a par: dimensões em que os dois vetores concordam empurram para cima, dimensões em que discordam empurram para baixo. Dividir pelas normas remove a influência do comprimento. Resultado em [−1, 1].
Produto escalar, termo a termo
a · b
‖a‖ · ‖b‖
Cosseno
Ângulo
Cosseno vs. distância euclidiana
‖â − b̂‖² = 2 − 2·sim(a,b)
Se os vetores estão normalizados, cosseno e distância euclidiana são a mesma informação, só reescalada ordenar por um é ordenar pelo outro (em ordem inversa). Escolher entre cosine e l2 no seu índice só muda o resultado se você não normalizou.
O que o cosseno não diz
Não existe limiar universal. 0,82 pode ser "muito relacionado" num modelo e "quase nada" em outro a distribuição depende do modelo e do domínio. Calibre com dados seus: pegue 50 pares que você sabe que combinam e 50 que não, e veja onde as distribuições se separam. Nunca chute score > 0.8 e mande para produção.
as três formas de escrever a mesma conta
constdot = (a, b) => a.reduce((s, x, i) => s + x * b[i], 0);
constnorm = (a) => Math.sqrt(dot(a, a));
constcosine = (a, b) => dot(a, b) / (norm(a) * norm(b) || 1);
// se você já normalizou na ingestão, o denominador é 1:constcosineFast = (a, b) => dot(a, b); // 2x mais rápido
import numpy as np
defcosine(a, b):
returnfloat(a @ b / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-12))
# em lote: query [d] contra base [N, d] já normalizada
scores = base @ query # [N] um BLAS, não um loop
top = np.argpartition(-scores, 10)[:10]
top = top[np.argsort(-scores[top])]
-- pgvector: <=> é DISTÂNCIA de cosseno (1 - similaridade)SELECT id, content,
1 - (embedding <=> $1) AS similarity
FROM chunks
ORDER BY embedding <=> $1-- ASC: menor distância primeiroLIMIT10;
-- <#> = produto escalar negativo | <-> = L2-- ORDER BY tem que usar o MESMO operador do índice,-- senão o planner ignora o HNSW e faz seq scan.
O resultado mais famoso do word2vec: se significado é geometria, então direções no espaço carregam relações. A direção que separa rei de rainha é aproximadamente a mesma que separa homem de mulher. Some e subtraia vetores e você navega por essas relações. Monte a sua equação:
Calcula-se o vetor a − b + c e procura-se o vocábulo mais próximo dele. Repare no w ∉ {a,b,c}: os três termos de entrada precisam ser excluídos da busca. Sem essa exclusão, o vencedor costuma ser o próprio c o resultado fica dominado pelo termo que você acabou de somar. Boa parte da mística em torno de "rei − homem + mulher = rainha" depende dessa linha de código que quase nunca aparece nos slides.
Mais próximos do vetor resultante
Esmaecidos: os termos de entrada, que a fórmula manda excluir. Repare onde eles cairiam.
Honestidade sobre o truque
Este espaço é de brinquedo: 8 dimensões desenhadas à mão, com ruído determinístico somado para não dar 1,00 redondo. Em embeddings reais a analogia funciona, mas com margens estreitas o vencedor legítimo costuma ficar em 0,70–0,85 e a diferença para o segundo colocado é pequena. Analogia vetorial é uma demonstração de que o espaço tem estrutura, não um método de produção.
O espaço herda os vieses do corpus
A mesma geometria que produz rei − homem + mulher ≈ rainha produz analogias profissionais e raciais enviesadas, porque a direção foi aprendida de texto humano em escala. Isso não é bug do modelo: é o corpus, medido. Se o seu ranking de busca ou seu filtro de currículos roda sobre embeddings, o viés entra por aí e só aparece se você medir explicitamente.
Buscar é achar os k vizinhos mais próximos de um vetor de consulta. Com poucos itens, você compara com todos. Com milhões, comparar com todos é caro demais e é aí que entram os índices aproximados. Escolha a consulta:
Top 5 vizinhos
O mesmo espaço, projetado em 2D
Projeção PCA de verdade, calculada no seu navegador a partir dos 8 eixos: os dois componentes que mais explicam a variância do vocabulário. Os vizinhos ficam destacados.
← componente 1 →↕ componente 2
Fórmula · busca exata (força bruta)
top-k = argmaxki ∈ N ( q · vi ) → custo O(N · d)
Comparar a consulta com todos os N vetores custa N×d multiplicações. Exato e simples e perfeitamente aceitável até algo na casa de dezenas de milhares de vetores, porque um produto matriz-vetor em BLAS é absurdamente rápido. Acima disso, o custo linear começa a doer.
Força bruta vs. índice aproximado
Ordens de grandeza para 768 dimensões em CPU comum ilustrativas, não benchmark. O ponto é a forma das curvas: força bruta cresce linear com N, HNSW cresce perto de logarítmico. O preço do HNSW é recall < 100%: ele pode perder vizinhos legítimos, e você regula esse trade-off com ef_search.
índice + consulta
-- HNSW: construção lenta, consulta rápida, sem passo de treinoCREATE INDEX chunks_emb_idx ON chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
-- por sessão: recall maior, consulta mais lentaSET hnsw.ef_search = 100; -- default 40SELECT id, content, 1 - (embedding <=> $1) AS score
FROM chunks
WHERE tenant_id = $2-- filtro ANTES do ORDER BYORDER BY embedding <=> $1LIMIT10;
-- Atenção: filtro muito seletivo + HNSW = recall despenca.-- O índice caminha o grafo e descarta o que não passa no-- WHERE, podendo voltar com menos de 10 linhas. Para isso-- existe índice particionado por tenant, ou pré-filtro.
import numpy as np
# base: [N, d] com TODAS as linhas normalizadas (L2)deftop_k(query, base, k=10):
q = query / np.linalg.norm(query)
scores = base @ q # O(N*d), mas em BLAS
idx = np.argpartition(-scores, k)[:k] # O(N), não O(N log N)return idx[np.argsort(-scores[idx])], scores
# medir recall do índice aproximado contra a verdade exatadefrecall_at_k(approx_ids, exact_ids, k=10):
returnlen(set(approx_ids[:k]) & set(exact_ids[:k])) / k
RAG é a aplicação mais comum de embeddings: em vez de esperar que o modelo saiba a resposta, você busca o trecho certo e o coloca no prompt. O LLM deixa de ser uma enciclopédia e vira um leitor. Dois tempos, e é essencial não confundi-los:
O pipeline completo
Chunking é onde a maioria falha
O chunk é ao mesmo tempo a unidade de busca e a unidade de contexto e o tamanho ideal para uma coisa raramente é o ideal para a outra. Chunk grande dilui o vetor (vira uma média de vários assuntos e não casa com nada). Chunk pequeno casa bem, mas chega ao LLM sem o contexto necessário para ser entendido. Corte em fronteiras semânticas títulos, parágrafos, seções e não a cada N caracteres cegos.
Vetor sozinho não basta
Busca vetorial erra feio em identificadores: código de erro, SKU, nome próprio raro, número de contrato. Ela busca sentido, e um SKU não tem sentido tem forma. A solução padrão é busca híbrida: rodar BM25 (léxico) e vetorial em paralelo e fundir os rankings com RRF (Reciprocal Rank Fusion), que não exige calibrar escalas de score diferentes.
o ciclo inteiro
# INDEXAÇÃO roda uma vez por documento (offline)defchunk(text, target=700, overlap=120):
"""Corta em parágrafos e agrupa até ~target caracteres.
O overlap evita que uma frase-chave morra na fronteira."""
paras, buf, out = text.split("\n\n"), "", []
for p in paras:
iflen(buf) + len(p) > target and buf:
out.append(buf)
buf = buf[-overlap:]
buf += p + "\n\n"return [c.strip() for c in out + [buf] if c.strip()]
for doc in documents:
for c inchunk(doc.text):
# prefixo assimétrico: documento != consulta
v = embed(f"passage: {c}")
db.insert(doc_id=doc.id, content=c, embedding=v)
# CONSULTA roda a cada pergunta (online)defanswer(question: str) -> str:
qv = embed(f"query: {question}") # o outro prefixo
hits = db.search(qv, k=20) # pesca largo…
hits = rerank(question, hits)[:5] # …e refina com cross-encoder
context = "\n\n---\n\n".join(
f"[{h.doc_id}#{h.id}] {h.content}"for h in hits
)
returnllm(
system="Responda SÓ com base no contexto. Cite [doc#chunk]. ""Se o contexto não responder, diga que não sabe.",
user=f"<contexto>\n{context}\n</contexto>\n\n{question}",
)
# O reranker é um cross-encoder: lê pergunta E trecho JUNTOS.# É lento demais para varrer a base (por isso o k=20 antes),# e bem mais preciso que cosseno para ordenar o topo.
-- Híbrida: BM25 e vetorial em paralelo, fundidos por RRF.-- score_rrf = Σ 1 / (k + posição), k=60 é o valor usual.WITH lexical AS (
SELECT id, row_number() OVER (
ORDER BYts_rank(tsv, websearch_to_tsquery($1)) DESC) AS pos
FROM chunks WHERE tsv @@ websearch_to_tsquery($1) LIMIT50
), semantic AS (
SELECT id, row_number() OVER (ORDER BY embedding <=> $2) AS pos
FROM chunks ORDER BY embedding <=> $2LIMIT50
)
SELECT c.id, c.content,
coalesce(1.0 / (60 + l.pos), 0) +
coalesce(1.0 / (60 + s.pos), 0) AS rrf
FROM chunks c
LEFT JOIN lexical l ON l.id = c.id
LEFT JOIN semantic s ON s.id = c.id
WHERE l.id IS NOT NULLOR s.id IS NOT NULLORDER BY rrf DESCLIMIT10;
Checklist antes de pôr em produção
Versione o modelo de embedding junto do vetor. Trocar de modelo invalida o índice inteiro: vetores de modelos diferentes não são comparáveis. Reindexação completa, sem atalho.
Meça recall num conjunto de perguntas reais com a resposta anotada. Sem isso você está ajustando parâmetros no escuro.
Guarde o texto original ao lado do vetor. Você precisa dele para o prompt, para citar a fonte e para reindexar.
Cacheie embeddings por hash do texto. Reprocessar um documento inalterado é dinheiro e latência jogados fora.
Isole por tenant no índice, não só no WHERE. Filtro pós-busca com ANN devolve menos resultados do que você pediu e pode vazar entre clientes se o filtro falhar.
Juntando tudo, do texto cru até a resposta citada:
No fim: um embedding é um endereço. O modelo aprendeu a colocar textos parecidos em endereços vizinhos, e todo o resto busca semântica, RAG, clustering, deduplicação é consequência de saber medir distância entre endereços. Não há mágica: há uma matriz, um pooling, uma normalização e um produto escalar.