0xEtern4lW0lf
IA local

Banco vetorial na prática: embeddings, similaridade de cosseno e Qdrant

7 min de leitura
#Qdrant#vector database#embeddings#RAG#chunking#similaridade

Quinto post da série sobre o Konhecia. No anterior vimos os dois modelos: um que gera texto e um que transforma texto em vetor. Este post é sobre onde esses vetores moram e como são consultados.

É a peça que a maioria dos tutoriais de RAG trata como caixa-preta — e é onde estão as decisões que determinam se o agente responde bem ou inventa com confiança.

O que é um embedding, sem misticismo

Um embedding é uma lista de números que representa o significado de um texto. No nosso caso, 768 números.

O que torna isso útil é uma propriedade só: textos com significado próximo geram vetores próximos no espaço. “Como resetar minha senha” e “esqueci minha credencial de acesso” não compartilham quase nenhuma palavra, mas caem perto um do outro. “Política de férias” cai longe dos dois.

É isso. Busca semântica é geometria: converta a pergunta num ponto, encontre os pontos mais próximos, devolva os textos que os originaram.

E é aí que está a diferença para a busca por palavra-chave tradicional. grep encontra “senha”; não encontra “credencial de acesso”. Busca vetorial encontra os dois — e erra em outros casos, porque proximidade semântica não é a mesma coisa que relevância para a pergunta. Guarde essa frase; ela volta no fim do post.

Similaridade de cosseno

Para medir “proximidade” entre dois vetores, a métrica padrão é o cosseno do ângulo entre eles:

  • ângulo 0° → cosseno 1 → mesma direção, significado praticamente idêntico;
  • ângulo 90° → cosseno 0 → ortogonais, sem relação;
  • ângulo 180° → cosseno -1 → direções opostas.

Por que ângulo e não distância em linha reta? Porque o comprimento do vetor carrega mais o tamanho do texto do que o significado. Um parágrafo longo e um resumo curto sobre o mesmo assunto apontam na mesma direção, mas têm magnitudes diferentes. Comparar por ângulo ignora a magnitude e olha só a direção — que é onde mora o sentido.

Por isso a collection é criada com distância cosseno:

client.create_collection(
    collection_name=settings.qdrant_collection_name,
    vectors_config=VectorParams(size=settings.qdrant_vector_size, distance=Distance.COSINE),
)

Os dois parâmetros são um contrato com o modelo de embedding: size=768 precisa bater com o que o modelo produz, e Distance.COSINE precisa ser a métrica para a qual ele foi treinado. Errar qualquer um dos dois não dá erro — dá resultado ruim, silenciosamente. Que é muito pior.

Chunking: a decisão mais subestimada

Antes de virar vetor, o documento precisa ser cortado. Você não indexa um PDF de 40 páginas como uma unidade: o vetor resultante seria uma média sem foco de tudo que o documento fala, próximo de qualquer pergunta e útil para nenhuma.

Então: PDF → páginas → chunks.

RAG_CHUNK_SIZE=1000
RAG_CHUNK_OVERLAP=150

chunk_size é o tamanho do pedaço em caracteres. O trade-off é direto:

  • chunk grande → mais contexto por trecho, mas o embedding fica diluído entre vários assuntos e a busca perde precisão;
  • chunk pequeno → embedding focado e busca precisa, mas o trecho recuperado pode não conter informação suficiente para responder.

Mil caracteres é um ponto de partida razoável para documentação — cerca de um a dois parágrafos. Não é resposta universal: documento técnico denso pede menos, texto corrido pede mais.

chunk_overlap é a sobreposição entre chunks consecutivos. Existe por um motivo específico: um corte cego parte frases e ideias no meio. Se o procedimento começa no fim de um chunk e termina no início do seguinte, nenhum dos dois responde a pergunta sozinho. A sobreposição de 150 caracteres garante que a fronteira apareça inteira em pelo menos um dos lados.

O splitter recursivo tenta cortar em fronteiras naturais antes de cortar no tamanho bruto — parágrafo, depois linha, depois frase, depois palavra:

splitter = RecursiveCharacterTextSplitter(
    chunk_size=settings.rag_chunk_size,
    chunk_overlap=settings.rag_chunk_overlap,
)

Metadados: sem eles, não há citação

Um chunk sem procedência é inútil num agente corporativo. O usuário precisa saber de onde veio a resposta para poder conferir. Então cada chunk carrega um payload estruturado:

chunk.metadata = {
    "source": source_path.name,       # nome do arquivo, para exibir na citação
    "page": raw_page + 1,             # 1-indexado
    "doc_id": doc_id,                 # id estável do documento de origem
    "ingested_at": ingested_at,       # timestamp UTC da ingestão
}

Dois detalhes que só aparecem quando você usa:

O + 1 na página. O carregador de PDF entrega páginas indexadas a partir do zero. Citar “página 0” para um usuário que está com o documento na mão é um bug de confiança — ele vai procurar e não achar. Converta na fronteira, uma vez, e nunca mais pense nisso.

Guardar só o nome do arquivo, não o caminho. O caminho completo dentro do container (/code/data/documents/manual.pdf) não significa nada para o usuário e vaza estrutura interna de diretórios numa resposta de API.

Idempotência: reingerir não pode duplicar

Cenário garantido: o manual é atualizado e alguém reenvia o arquivo. Sem cuidado, você agora tem duas versões do mesmo conteúdo indexadas — e o agente vai recuperar chunks da versão velha e da nova, misturados, sem nenhuma forma de saber qual está correta.

A solução é um identificador estável derivado do caminho de origem, e uma remoção antes da inserção:

def _doc_id_for(source_path: Path) -> str:
    """Mesmo caminho -> mesmo id, sempre."""
    return str(uuid.uuid5(uuid.NAMESPACE_URL, str(source_path.resolve())))

uuid5 é um UUID determinístico: mesma entrada, mesma saída, sempre — ao contrário do uuid4, que é aleatório. Com ele:

vector_store.client.delete(
    collection_name=settings.qdrant_collection_name,
    points_selector=Filter(
        must=[FieldCondition(key="metadata.doc_id", match=MatchValue(value=doc_id))]
    ),
)
vector_store.add_documents(chunks)

Apaga os chunks daquele documento, insere os novos. Reenviar o mesmo arquivo dez vezes deixa a base no mesmo estado que enviá-lo uma. Ingestão é uma operação que vai ser repetida — projete para isso desde o começo, porque descobrir depois significa uma base contaminada e nenhuma forma fácil de limpar.

Busca: top-k e limiar de relevância

Na hora da pergunta, dois parâmetros governam o que chega ao modelo:

RAG_RETRIEVER_TOP_K=4
RAG_MIN_RELEVANCE_SCORE=0.5

top_k é quantos chunks recuperar. Poucos, e a resposta pode faltar contexto. Muitos, e você enche a janela de contexto com ruído — o que piora a resposta, além de deixá-la mais lenta. Mais contexto não é melhor contexto.

min_relevance_score descarta o que veio abaixo de um limiar de similaridade:

async def retrieve_context(question, settings):
    vector_store = get_vector_store(settings)
    results = await vector_store.asimilarity_search_with_score(
        question, k=settings.rag_retriever_top_k
    )
    return [(doc, score) for doc, score in results if score >= settings.rag_min_relevance_score]

Isso existe porque a busca vetorial sempre devolve algo. Peça os 4 mais próximos numa base sobre suporte de TI, pergunte sobre a capital da França, e você recebe 4 chunks — os menos distantes que existem, ainda que não tenham nada a ver. Sem limiar, esse lixo vai para o prompt como se fosse contexto legítimo.

Com o limiar, se nada passa, a resposta é uma recusa sem sequer chamar o LLM. Mais rápido, mais barato e sem chance de o modelo tentar ser útil com material irrelevante.

E aqui está a armadilha

Escrevi acima que proximidade semântica não é relevância. Aqui está a fatura.

O limiar de score é mais permissivo do que parece. Modelos de embedding atribuem uma similaridade “de fundo” relativamente alta até para pares de texto sem relação real. Dois textos em português, no mesmo registro formal, sobre assuntos completamente diferentes, raramente ficam ortogonais — não caem para perto de zero.

O efeito prático: 0.5 deixa passar coisa que não deveria. Subir o limiar tampouco resolve — um teste do projeto verifica justamente que, com 0.99, nenhum chunk real passa, nem os corretos. A faixa útil é estreita e depende do modelo, do idioma e do corpus. Não existe número universal para calibrar.

A conclusão é a que orientou o resto do projeto: o limiar de score é uma primeira camada de defesa, não a defesa. Ele barata o caso fácil (a base está vazia, a pergunta é de outro planeta). Não barra o caso interessante: chunks que pontuam razoavelmente e mesmo assim não respondem à pergunta.

Para esse caso é preciso uma segunda camada, no prompt — e é o assunto do próximo post, sobre montar a chain RAG com LCEL.

Validando na mão

Antes de conectar qualquer LLM, vale checar a qualidade da recuperação isolada. Um subcomando de CLI que só faz a busca e imprime os scores:

make query q="Como faço para resetar minha senha?"
[1] score=0.7161 fonte=manual_de_suporte.pdf pagina=1
    Para redefinir sua senha, acesse o portal interno e clique em...

Esse hábito economiza horas. Quando a resposta final vem ruim, a primeira pergunta é se o problema é recuperação ou geração — e essas são causas completamente diferentes. Se os chunks certos nem chegaram, mexer no prompt não vai resolver nada. Sem uma forma de olhar só a recuperação, você vai passar a tarde ajustando a camada errada.