Embeddings em busca de significado: explicação detalhada com exemplos

Este artigo tem um vídeo curto:YouTubeTikTok

1. Visão geral

Quando você pergunta a um chatbot “Meu voo foi cancelado, consigo reembolso da hospedagem?”, o sistema RAG precisa encontrar o trecho correto da política que fala sobre “interrupção da viagem” — mesmo que a pergunta não contenha nenhuma palavra que corresponda 100% com o texto original. Este artigo explica o mecanismo de embeddings que permite isso: transformar texto em vetores, fazer correspondência pela distância semântica, e armazenar de forma que o LLM consiga rastrear o texto original. Ao final, você conseguirá escrever sozinho um pipeline simples de busca semântica em Java, com testes que comprovam seu funcionamento.

2. O que é embedding

Um modelo de embedding recebe um trecho de texto e retorna um vetor — uma sequência de números reais, geralmente de algumas centenas a milhares de dimensões. Este vetor não é “palavras-chave comprimidas”, mas sim um ponto em um espaço multidimensional, onde trechos de texto com significado semelhante terão vetores próximos um do outro.

public record EmbeddingVector(float[] values) {
    public int dimension() {
        return values.length;
    }
}

EmbeddingVector é apenas um wrapper para float[], mas ajuda você a evitar passar float[] confundindo entre diferentes modelos — um erro muito comum quando o sistema cresce.

2.1. Por que não pensar em embeddings como um “mapa” com rótulos claros

Em um mapa real, você tem eixos de longitude, latitude, com rótulos “Hanói”, “Danang”. Com embeddings, cada dimensão individual não tem rótulo como “hotel”, “voo”, “reembolso”. O significado emerge apenas através de padrões combinados do vetor inteiro. Portanto, você não consegue “ler” uma dimensão para descobrir o que ela representa — você só pode comparar o vetor inteiro com outro vetor.

Isso é importante porque explica por que você não deve cortar o vetor arbitrariamente — por exemplo, manter apenas 100 de 1536 dimensões para economizar memória — fazer isso quebra o padrão e a precisão da busca cai drasticamente.

3. Correspondência pela distância semântica

Dois vetores “próximos” geralmente são medidos por cosine similarity — quanto mais próximo de 1, mais semelhante em significado.

public final class VectorMath {

    public static double cosineSimilarity(float[] a, float[] b) {
        if (a.length != b.length) {
            throw new IllegalArgumentException(
                "Vector dimension mismatch: %d vs %d".formatted(a.length, b.length));
        }
        double dot = 0, normA = 0, normB = 0;
        for (int i = 0; i < a.length; i++) {
            dot += a[i] * b[i];
            normA += a[i] * a[i];
            normB += b[i] * b[i];
        }
        return dot / (Math.sqrt(normA) * Math.sqrt(normB));
    }
}
  • Linha if (a.length != b.length): bloqueia imediatamente o erro comum — comparar vetores de dois modelos diferentes (por exemplo, um tem 768 dimensões, outro tem 1536) gerará um erro de runtime difícil de debugar se não verificar cedo.
  • dot, normA, normB: calcula o produto escalar e a magnitude de cada vetor pela fórmula de cosseno padrão.

Nota: dois modelos com o mesmo número de dimensões não significa que sejam compatíveis. O modelo A e o modelo B ambos geram vetores de 1536 dimensões, mas seus espaços semânticos são diferentes — comparação cruzada dará resultados sem sentido mesmo que o código rode sem erros.

4. Simulando um modelo de embedding para praticar sem chamar API real

Para o exemplo rodar imediatamente, usarei um EmbeddingClient simulado baseado na frequência de palavras-chave pertencentes a “tópicos” (travel, password, billing). Na prática, você substituiria por OpenAI, Cohere ou modelos de código aberto, mas a interface permanece.

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-openai-spring-boot-starter</artifactId>
    <version>1.0.0</version>
</dependency>
public interface EmbeddingClient {
    EmbeddingVector embed(String text);
}
public class KeywordEmbeddingClient implements EmbeddingClient {

    private static final List<String> TRAVEL_WORDS =
        List.of("voo", "cancelar", "hotel", "viagem", "aeroporto");
    private static final List<String> PASSWORD_WORDS =
        List.of("senha", "login", "conta", "resetar", "esquecer");
    private static final List<String> BILLING_WORDS =
        List.of("reembolso", "pagamento", "fatura", "custo", "taxa");

    @Override
    public EmbeddingVector embed(String text) {
        String normalized = text.toLowerCase();
        float travel = score(normalized, TRAVEL_WORDS);
        float password = score(normalized, PASSWORD_WORDS);
        float billing = score(normalized, BILLING_WORDS);
        return new EmbeddingVector(new float[] { travel, password, billing });
    }

    private float score(String text, List<String> keywords) {
        long matches = keywords.stream().filter(text::contains).count();
        return matches / (float) keywords.size();
    }
}

KeywordEmbeddingClient usa apenas 3 dimensões (travel/password/billing) para facilitar a observação — embeddings reais usam centenas de dimensões aprendidas a partir dos dados, mas a ideia “textos com significado semelhante → vetores próximos” é a mesma.

5. Armazenando embeddings com metadados para o LLM rastrear o texto original

Vetores apenas ajudam a localizar o texto, mas o LLM ainda precisa do conteúdo para responder. Portanto, cada registro no vector store deve conectar o embedding ao chunk original e metadados.

public record DocumentChunk(
    String chunkId,
    String documentId,
    String text,
    String section,
    LocalDate effectiveDate,
    int version,
    EmbeddingVector embedding
) {}
  • chunkId e documentId: permitem recuperar o documento completo quando necessário mostrar a fonte ou atualizar para uma nova versão.
  • section, effectiveDate, version: ajudam a filtrar antes de fazer correspondência semântica — por exemplo, procurar apenas em políticas vigentes, ignorando versões antigas expiradas.
  • text: obrigatório armazenar junto, pois o LLM não consegue “ler” vetores — ele precisa do texto real para gerar uma resposta.
public class InMemoryVectorStore {

    private final List<DocumentChunk> chunks = new ArrayList<>();

    public void add(DocumentChunk chunk) {
        chunks.add(chunk);
    }

    public List<DocumentChunk> findNearest(EmbeddingVector query, int topK) {
        return chunks.stream()
            .sorted(Comparator.comparingDouble(
                (DocumentChunk c) -> -VectorMath.cosineSimilarity(
                    query.values(), c.embedding().values())))
            .limit(topK)
            .toList();
    }
}

findNearest ordena os chunks por cosine similarity em ordem decrescente (o sinal - inverte a ordem do Comparator) e pega os topK resultados mais próximos — este é precisamente o passo “retrieval” em RAG.

6. Exemplo completo: pergunta diferente em texto mas correta em significado

public class SemanticSearchDemo {

    public static void main(String[] args) {
        EmbeddingClient embedder = new KeywordEmbeddingClient();
        InMemoryVectorStore store = new InMemoryVectorStore();

        store.add(new DocumentChunk(
            "chunk-1", "policy-travel", "Política de reembolso quando o voo é cancelado ou a viagem é interrompida.",
            "Travel Disruption", LocalDate.of(2026, 1, 1), 2,
            embedder.embed("Política de reembolso quando o voo é cancelado ou a viagem é interrompida.")
        ));

        store.add(new DocumentChunk(
            "chunk-2", "policy-account", "Guia para resetar senha quando esquece a conta de login.",
            "Account Recovery", LocalDate.of(2025, 6, 1), 1,
            embedder.embed("Guia para resetar senha quando esquece a conta de login.")
        ));

        String question = "Meu voo foi cancelado, consigo reembolso do hotel que já paguei?";
        EmbeddingVector queryVector = embedder.embed(question);

        List<DocumentChunk> results = store.findNearest(queryVector, 1);
        System.out.println(results.get(0).text());
    }
}

Executar este programa imprime:

Política de reembolso quando o voo é cancelado ou a viagem é interrompida.

A pergunta não contém as palavras “política” ou “viagem”, mas porque a pergunta e o texto estão próximos no espaço semântico (a dimensão “travel” é alta em ambos), o sistema escolhe o chunk correto — ignorando completamente o chunk sobre resetar senha.

7. Verificação por testes

class SemanticSearchTest {

    private final EmbeddingClient embedder = new KeywordEmbeddingClient();
    private final InMemoryVectorStore store = new InMemoryVectorStore();

    @BeforeEach
    void setUp() {
        store.add(new DocumentChunk(
            "chunk-1", "policy-travel",
            "Política de reembolso quando o voo é cancelado ou a viagem é interrompida.",
            "Travel Disruption", LocalDate.of(2026, 1, 1), 2,
            embedder.embed("Política de reembolso quando o voo é cancelado ou a viagem é interrompida.")
        ));
        store.add(new DocumentChunk(
            "chunk-2", "policy-account",
            "Guia para resetar senha quando esquece a conta de login.",
            "Account Recovery", LocalDate.of(2025, 6, 1), 1,
            embedder.embed("Guia para resetar senha quando esquece a conta de login.")
        ));
    }

    @Test
    void shouldMatchTravelPolicyEvenWithoutSharedWords() {
        String question = "Meu voo foi cancelado, consigo reembolso do hotel que já paguei?";
        EmbeddingVector queryVector = embedder.embed(question);

        List<DocumentChunk> results = store.findNearest(queryVector, 1);

        assertThat(results)
            .hasSize(1)
            .first()
            .extracting(DocumentChunk::documentId)
            .isEqualTo("policy-travel");
    }

    @Test
    void shouldRejectComparingDifferentDimensions() {
        float[] a = { 1f, 0f, 0f };
        float[] b = { 1f, 0f };

        assertThatThrownBy(() -> VectorMath.cosineSimilarity(a, b))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessageContaining("dimension mismatch");
    }
}

O primeiro teste afirma o comportamento central: uma pergunta com redação diferente do texto original ainda encontra o chunk correto. O segundo teste garante que você não compare acidentalmente dois vetores incompatíveis — esse erro é muito fácil de ocorrer quando você troca o modelo de embedding e esquece de re-embutir todos os dados antigos.

8. Erros comuns e quando não usar

  • Trocar o modelo de embedding sem re-embutir os dados antigos: os vetores antigos e novos não estão no mesmo espaço, a correspondência dará resultados incorretos silenciosamente — nenhuma exceção o avisará.
  • Chunk muito longo ou muito curto: chunks longos fazem o vetor “misturar” muitas ideias, reduzindo a precisão; chunks muito curtos perdem contexto. Geralmente 200–500 tokens é um ponto de partida razoável, precisa ser ajustado por domínio.
  • Esquecer de armazenar metadados: se você armazenar apenas vetores sem documentId/version, você não consegue deletar ou atualizar o documento correto quando a política muda.
  • Usar embedding para dados que precisam de correspondência exata: se você precisa encontrar o código exato do pedido ORD-2026-00123, busca de texto completo ou índice por chave exata ainda são mais rápidos e precisos do que embeddings — embeddings são fortes em busca por significado, não por correspondência exata de strings.

Nota: com um conjunto de dados pequeno (algumas centenas de chunks), o InMemoryVectorStore acima é suficiente. Quando escala para milhões de chunks, você precisa de um banco de dados de vetores especializado (pgvector, Pinecone, Milvus…) que suporte indexação aproximada (ANN) para evitar comparação sequencial de cada vetor.

9. Conclusão

Embeddings transformam texto em vetores para que máquinas possam comparar “significado” em vez de apenas encontrar palavras-chave — essa é a base de todo sistema RAG. O ponto-chave a lembrar: query e document devem usar o mesmo encoder compatível, cada vetor deve vir com metadados para rastrear o texto original, e ter o mesmo número de dimensões não garante que dois modelos sejam compatíveis. A partir do exemplo Java acima, você pode substituir KeywordEmbeddingClient por um modelo de embedding real (via Spring AI ou API direto) enquanto todo o resto — cosine similarity, vector store, metadados — permanece igual.

ragembeddingvector-searchspring-aillm