FMFelipe MiillerNotes on software & systems
HomeBlogAbout
GitHub

Keep building.

Felipe Miiller · © 2026

MailGitHubGitHubLinkedinGitHub
View source on GitHub
Back to blog

Embeddings e bancos vetoriais: o que cada motor realmente faz

04/10/2026
20 min de leitura
5806 palavras
RAGPythonDataBase
  • 1. A escolha são três variáveis, e todo mundo mexe numa
  • 2. A métrica decide o ranking antes do índice existir
  • O modelo, quando você precisar escolher
  • 3. Índice: o que muda quando a base deixa de caber na memória
  • Inicialização determinística: um centro por assunto. Num IVF real isso sai de
  • k-means++ ou de um sorteio com semente; aqui é escolha para o resultado
  • não depender de sorte.
  • Consulta que fica no MEIO de dois agrupamentos: a resposta está repartida
  • entre as duas listas, e só quem sonda as duas encontra tudo.
  • O detalhe que ninguém quer ouvir
  • 4. O filtro é um quarto índice, e sem ele o filtro não existe
  • ERRADO: ordena o acervo inteiro, depois descarta o que não passa no filtro.
  • CERTO: restringe o acervo ANTES de ordenar, e ordena só o que sobrou.
  • 5. Calcular similaridade no SQL, quando o banco já existe
  • 6. Os quatro motores, e o que cada um é de fato
  • 7. Trocar o modelo é reindexar tudo
  • TL;DR
  • Referências

Lede. Escolher um banco vetorial parece uma escolha de nome, e quase sempre não é. Ela são três decisões independentes — modelo, métrica e índice — e o que decide a qualidade da busca é a métrica e o índice. Neste artigo você mede as três, descobre que a mesma base de vetores dá três rankings diferentes conforme a métrica, e vê por que um filtro sem índice é quase a mesma coisa como não filtrar. Onde você está na linha. Este é o passo 3 de 13 — o vetor e onde ele mora. Antes dele: 01 · Anatomia do pipeline (que definiu embedding e índice) e 02 · Chunking (que definiu o que entra em cada vetor). Depois dele: 04 · Busca híbrida, que junta esta busca com a busca por palavra. Este artigo pressupõe o conceito de embedding do 01; se você chegou direto aqui, a seção 3.4 dele é a definição, e ela é dezoito linhas.


1. A escolha são três variáveis, e todo mundo mexe numa

Uma base vetorial responde a uma pergunta: quais destes milhares de vetores são mais parecidos com este vetor? Para responder, o sistema precisa de três coisas, e

cada uma é uma decisão sua:

VariávelA pergunta que ela respondeOnde se decide
Modeloo que cada vetor significano carregador, fora do banco
Métricao que "parecido" quer dizerna criação da coleção
Índicecomo comparar um milhão de vetores rápidona criação da coleção

A ordem de peso é essa: métrica e índice pesam mais que o nome do modelo. Não porque o

modelo seja irrelevante — ele é metade do resultado — mas porque modelo é a escolha que todo mundo faz e as outras duas são as que ninguém faz. Você troca de modelo

quando ele é novo no mercado. Você nunca revisa a métrica, porque ela estava no

exemplo que você copiou.

E há uma quarta variável, invisível, que é a mais cobrada: o filtro. Filtro por

tenant, por data, por tipo de documento. Ele não é uma opção da busca, é o que decide

se a busca devolve o resultado certo. A seção 4 mostra o que acontece quando o filtro

não tem índice próprio.


2. A métrica decide o ranking antes do índice existir

Antes de falar de índice, é preciso falar do que ele vai ordenar. Um vetor é uma lista

de números; "parecido" precisa virar um número, e há três maneiras usuais de fazer

isso.

Cosseno. Mede o ângulo entre os vetores, ignorando o tamanho. Se dois textos dizem a

mesma coisa emabuados de palavras diferentes, o cosseno os aproxima. É a métrica padrão

da comunidade, e o código abaixo mostra por quê.

Produto interno. Multiplica e soma, sem dividir por nada. É o cosseno vezes a

norma dos dois vetores. Um documento longo ganha.

Euclidiana. A distância geométrica: raiz da soma dos quadrados das diferenças.

Menor é melhor, e ao contrário das outras duas.

Repare no que cada uma pressupõe. O cosseno pressupõe que o tamanho do vetor não importa — o que significa que você jogou fora a informação de "este documento é

mais detalhado que aquele". O produto interno pressupõe que o tamanho importa — e o

tamanho de um vetor de embedding tem correlação com o tamanho do texto. A euclidiana

pressupõe que o que importa é a distância absoluta, e ela pune documento longo

duas vezes: uma pela distância e outra porque ele fica longe do centro.

A forma de testar isso sem chamar modelo nenhum é construir os vetores com um

vetorizador de contagem de palavras. Ele não é semântico e não pretende ser: serve

para expor a aritmética das métricas. O detalhe que importa para o código é que

hash() de texto é sorteado por processo no Python 3, então o mesmo programa daria

saída diferente a cada execução. Por isso o índice da dimensão sai de um CRC, que é

estável:

from __future__ import annotations

import math
import zlib
from collections.abc import Sequence

DIM = 64


def word_slot(word: str, dim: int) -> int:
    """Em qual dimensão a palavra cai. Estável entre execuções.

    `hash()` de texto é sorteado por processo no Python 3 (PYTHONHASHSEED):
    usá-lo aqui faria o artigo printing números diferentes no seu computador.
    """
    return zlib.crc32(word.encode("utf-8")) % dim


def fake_vector(text: str, dim: int = DIM) -> list[float]:
    """Contagem de palavras projetada num vetor. NÃO é semântico."""
    vector = [0.0] * dim
    for word in text.lower().split():
        vector[word_slot(word, dim)] += 1.0
    return vector


def normalize(v: Sequence[float]) -> list[float]:
    """Divide pela norma. Depois disso, norma = 1."""
    norma = math.sqrt(sum(x * x for x in v)) or 1.0
    return [x / norma for x in v]


def dot(a: Sequence[float], b: Sequence[float]) -> float:
    return sum(x * y for x, y in zip(a, b, strict=True))


def cosine(a: Sequence[float], b: Sequence[float]) -> float:
    """Ângulo. Ignora o tamanho dos dois vetores."""
    return dot(normalize(a), normalize(b))


def euclidean(a: Sequence[float], b: Sequence[float]) -> float:
    """Distância. Menor é melhor. Olha o tamanho dos dois."""
    return math.sqrt(sum((x - y) ** 2 for x, y in zip(a, b, strict=True)))


PERGUNTA = "qual o limite de vibracao"
TEXTO = {
    "curto_exato": "limite vibracao 4,5 parar equipamento",
    "longo_ruim": " ".join([
        "vibracao", "limite", "4,5", "parar", "equipamento",
        "responsavel", "tecnico", "turno", "noite", "comunicado",
        "registro", "auditoria", "planilha", "assinatura", "digital",
        "solicitacao", "protocolo", "expediente", "plantao", "folga",
        "treinamento", "reciclagem", "uniforme", "credenciamento",
        "reposicao", "almoxarifado", "manutencao", "preventiva", "corretiva",
        "ordem", "servico", "execucao", "prazo", "entrega",
    ]),
    "temperatura": "temperatura 85 reduzir carga motor graus",
    "ruido": "ruido 92 revisar isolamento db",
}

vetores = {nome: fake_vector(texto) for nome, texto in TEXTO.items()}
query = fake_vector(PERGUNTA)

print(f"{'documento':<14}{'norma':>7}{'produto':>9}{'cosseno':>9}{'euclidiana':>12}")
for nome, v in vetores.items():
    print(
        f"{nome:<14}{math.sqrt(dot(v, v)):>7.1f}{dot(query, v):>9.1f}"
        f"{cosine(query, v):>9.3f}{euclidean(query, v):>12.3f}"
    )

A primeira coluna é a norma — o "tamanho" do vetor. A segunda é o produto interno, a

terceira o cosseno e a quarta a euclidiana:

documento       norma  produto  cosseno  euclidiana
curto_exato       2.2      2.0    0.338       2.828
longo_ruim        7.5      5.0    0.253       7.280
temperatura       2.8      0.0    0.000       3.873
ruido             2.2      0.0    0.000       3.464

Olhe o documento longo_ruim: é uma folha de ponto com 34 palavras, das quais cinco

são a resposta e o resto é burocracia. Ele tem a maior norma da tabela. Agora veja o

que cada métrica faz com ele:

cosseno            ['curto_exato', 'longo_ruim', 'temperatura', 'ruido']
produto interno    ['longo_ruim', 'curto_exato', 'temperatura', 'ruido']
euclidiana         ['curto_exato', 'ruido', 'temperatura', 'longo_ruim']

Três métricas, três rankings, o mesmo vetor. Pelo produto interno, a folha de ponto

é o primeiro resultado. Pela euclidiana, ela é o último. Pelo cosseno, fica no

meio. Você não escolheu isso: alguém escolheu por você, na linha em que a coleção foi

criada.

A conta por trás disso cabe em uma linha, e vale ter na cabeça:

Euclidiana ao quadrado é produto interno menos o quadrado da norma do documento. Por isso a euclidiana nunca promove um documento cujo tamanho é grande demais para o quanto ele tem em comum com a pergunta.

Para ver isso sem nenhum documento no meio, um experimento controlado: pegue um vetor e

dobre todas as coordenadas. O conteúdo é idêntico — só o "tamanho" mudou.

a = fake_vector("limite vibracao 4,5 parar equipamento")
b = [2 * x for x in a]   # mesma direção, norma o dobro

print(f"produto interno  {dot(query, a):.2f} -> {dot(query, b):.2f}")
print(f"cosseno          {cosine(query, a):.4f} -> {cosine(query, b):.4f}")
print(f"euclidiana       {euclidean(query, a):.4f} -> {euclidean(query, b):.4f}")
produto interno  2.00 -> 4.00
cosseno          0.3381 -> 0.3381
euclidiana       2.8284 -> 4.3589

O cosseno não se move, porque normaliza antes. O produto interno dobra, porque não

divide por nada. A euclidiana piora, porque a norma cresceu mais que o produto. É a

mesma frase de antes, agora sem nenhum documento no meio.

⚠️ Métrica errada não produz erro, produz ranking errado
Nenhuma biblioteca avisa que a métrica está errada. A busca responde, o documento

volta, a resposta sai — e sai com a folha de ponto no lugar do trecho certo. Se você

está com métrica de produto interno sem normalizar os vetores antes de gravar,

está ranqueando pelo tamanho do documento, e não pelo significado. Normalize na

gravação, não na consulta: assim o cálculo errado é visível nos dados.

O modelo, quando você precisar escolher

Escolher modelo por sentido de frase é o jeito mais comum de errar. A escolha honesta é

por métrica de recuperação medida em base pública, e o nome dessa métrica é

MTEB — um conjunto de tarefas de recuperação com

conjuntos de teste padronizados, que roda modelos de várias famílias e publica os

resultados. A regra prática: não escolha modelo pelo seu documento. Escolha pelo

desempenho publicado em tarefa parecida com a sua, e valide na sua base depois. O custo

de validar é o reindexamento da seção 7, e ele é o mesmo qualquer que seja o modelo.


3. Índice: o que muda quando a base deixa de caber na memória

A busca exata compara a consulta com todos os vetores. Com 200 vetores isso é

instantâneo. Com dois milhões, não. O índice existe para responder "quais são os 10

mais parecidos" sem comparar com todos.

Existem duas famílias, e elas erram de maneiras diferentes.

Busca exata (flat). Lê todos os vetores e ordena. Erra nada: o resultado é o

melhor possível. Custa tempo linear no tamanho da base.

Busca aproximada (ANN). Não lê todos. Ela aposta que a resposta está numa região

do espaço e lê só essa região. Custa menos tempo e erra uma parte das respostas. Duas

formas clássicas:

IVF particiona o espaço em células usando agrupamento (clustering): as células com

conteúdo parecido ficam na mesma lista invertida. Na consulta, em vez de olhar todos os

vetores, o índice olha as nprobe listas cujos centros estão mais perto da consulta.

Quanto maior nprobe, mais vetores são lidos e mais perto do resultado exato o índice fica.

HNSW faz a mesma aposta com uma estrutura diferente: um grafo em que cada vetor

aponta para os seus vizinhos mais próximos, e a busca salta de vértice em vértice pelo

caminho que sai mais rápido para perto da consulta. O detalhe que define a qualidade é o

parâmetro de quantidade de vizinhos por vértice: mais vizinhos, busca melhor e índice

maior. O DiskANN é o trabalho que mostra esse

caminho funcionando em escala de bilhões de pontos.

Nenhuma das duas é gratuita: a aproximada troca acerto por tempo, e o tamanho da

troca é um número que você escolhe (nprobe, número de vizinhos) e que precisa medir.

O código abaixo faz a troca explícita, com um IVF de verdade em Python puro, para você

ver a forma do negócio:

import time

TEMAS = {
    "garantia": "garantia prazo cobertura defeito troca produto",
    "vibracao": "vibracao limite mm parar equipamento eixo",
    "temperatura": "temperatura graus reduzir carga motor ventoinha",
    "registro": "registro data responsavel medicao auditoria",
    "ruido": "ruido isolamento revisar decibelo parede",
    "frequencia": "inspecao dias frequencia aprovacao responsavel",
    "contrato": "clausula rescisao multa prazo pagamento foro",
    "seguranca": "epi capacete luva norma seguranca",
}
NOMES = list(TEMAS)
POR_GRUPO = 30


def synthetico(por_grupo: int) -> list[list[float]]:
    """Um grupo de `por_grupo` documentos por assunto, sem mistura."""
    out: list[list[float]] = []
    for nome in NOMES:
        base = TEMAS[nome].split()
        for i in range(por_grupo):
            out.append(fake_vector(
                " ".join(base) * (1 + i % 2) + " " + base[i % len(base)]
            ))
    return out


def exact_search(vs, qv, k):
    """Busca exata: compara com todos e ordena."""
    notas = [(cosine(qv, v), i) for i, v in enumerate(vs)]
    notas.sort(key=lambda x: (-x[0], x[1]))
    return [i for _, i in notas[:k]]


def kmeans(vs, centroides, iters=10):
    """Agrupamento: move cada centro para a média dos seus vizinhos."""
    centroides = [list(c) for c in centroides]
    for _ in range(iters):
        grupos: list[list[list[float]]] = [[] for _ in range(len(centroides))]
        for v in vs:
            j = max(range(len(centroides)), key=lambda c: cosine(v, centroides[c]))
            grupos[j].append(v)
        for c, g in enumerate(grupos):
            if g:
                centroides[c] = [sum(x[i] for x in g) / len(g) for i in range(len(g[0]))]
    return centroides


def build_lists(vs, centroides):
    """Cada vetor entra na lista do centro mais próximo. É a lista invertida."""
    listas: list[list[int]] = [[] for _ in centroides]
    for i, v in enumerate(vs):
        j = max(range(len(centroides)), key=lambda c: cosine(v, centroides[c]))
        listas[j].append(i)
    return listas


def ivf_search(vs, listas, qv, k, nprobe):
    """IVF: ordena os centros, lê só as `nprobe` listas mais próximas."""
    n = len(qv)
    centros = [
        normalize([sum(vs[i][d] for i in l) / len(l) for d in range(n)]) for l in listas
    ]
    ordem = sorted(
        range(len(listas)),
        key=lambda c: -dot(normalize(qv), centros[c] if listas[c] else [0.0] * n),
    )
    candidatos = [i for c in ordem[:nprobe] for i in listas[c]]
    notas = sorted(
        ((cosine(qv, vs[i]), i) for i in candidatos),
        key=lambda x: (-x[0], x[1]),
    )
    return [i for _, i in notas[:k]], len(candidatos)


vs = synthetico(POR_GRUPO)
# Inicialização determinística: um centro por assunto. Num IVF real isso sai de
# k-means++ ou de um sorteio com semente; aqui é escolha para o resultado
# não depender de sorte.
centroides = kmeans(vs, [vs[i * POR_GRUPO] for i in range(len(NOMES))])
listas = build_lists(vs, centroides)
# Consulta que fica no MEIO de dois agrupamentos: a resposta está repartida
# entre as duas listas, e só quem sonda as duas encontra tudo.
meio = normalize([
    a + b for a, b in zip(normalize(centroides[1]), normalize(centroides[2]))
])

t0 = time.perf_counter()
exatos = exact_search(vs, meio, 10)
t_exato = time.perf_counter() - t0
for nprobe in (1, 2, len(NOMES)):
    t0 = time.perf_counter()
    aprox, varridos = ivf_search(vs, listas, meio, 10, nprobe)
    t_aprox = time.perf_counter() - t0
    # O tempo é medido, mas não é impresso: o número depende da máquina, e a
    # saída deste artigo precisa ser a mesma que a sua. Para ver o tempo,
    # acrescente ` {t_aprox * 1000:.2f} ms` ao `print`.
    print(
        f"IVF nprobe={nprobe} varridos={varridos} "
        f"recall@10={len(set(exatos) & set(aprox)) / 10:.2f}"
    )
print(f"exata varridos={len(vs)} recall@10=1.00")
print("exatos:", exatos)
print("nprobe=1:", ivf_search(vs, listas, meio, 10, 1)[0])

A saída mostra as três coisas que importam:

IVF nprobe=1 varridos=30 recall@10=0.50
IVF nprobe=2 varridos=60 recall@10=1.00
IVF nprobe=8 varridos=240 recall@10=1.00
exata varridos=240 recall@10=1.00
exatos: [35, 41, 47, 53, 59, 61, 67, 73, 79, 85]
nprobe=1: [35, 41, 47, 53, 59, 32, 34, 38, 40, 44]

O índice leu oito vezes menos vetores (30 contra 240) e devolveu metade da resposta certa. Os cinco primeiros resultados batem exatamente com os da busca exata, e a partir

do sexto o índice está devolvendo outra coisa: a resposta estava repartida entre as duas

listas, e ele só sondou uma. Isso não é defeito do IVF, é o contrato dele: a resposta

que não estava na região lida não existe para a busca.

Com nprobe=2 a resposta volta inteira. Com nprobe=8 (todas as listas) o IVF vira

exata, e paga o preço de ter lido tudo. O índice não é um acelerador, é uma aposta com parâmetro. E o recall da busca aproximada é uma curva, não um número: você escolhe

onde fica nela.

O detalhe que ninguém quer ouvir

O exemplo acima tem 240 vetores, e nele a busca exata ganha nos dois critérios: ela é

100% correta e mais rápida que a aproximada que sonda tudo. Isso não é acaso. Enquanto a

base cabe em memória e o número de vetores é pequeno, a busca exata costuma ganhar em

acerto e em latência: não tem recall a perder, não tem lista invertida para varrer, e

em cima disso evita custo de construção do índice. A regra prática, e ela é regra, não

medição: abaixo de algumas dezenas de milhares de pontos, comece com busca exata.

Onde o ponto vira depende da dimensão do vetor, do hardware, da biblioteca e do quanto

você pode pagar por recall perdido. Ninguém te dá esse número de graça. O que a regra

prática faz é evitar a decisão errada mais comum, que é construir índice aproximado

para uma base de cinco mil pontos e nunca mais pensar nisso — construir o índice

custa tempo e memória de todo mundo, e o índice aproximado só começa a compensar muito

depois do ponto em que a base já incomoda.

⚠️ Índice aproximado sem recall medido é chute com latência
Se o seu índice é aproximado e você nunca mediu quanto ele perde, você não sabe se

está perdendo 1% ou 30% das respostas — e a diferença entre esses dois números é

exatamente a diferença entre "o sistema funciona" e "o sistema funciona e você não

sabe por quê". Meça o recall@10 contra a busca exata, periodicamente, e não só no

dia em que criou o índice. O número de vetoes do seu [artigo

11](https://www.felipemiiller.com/blog/post/observar-e-avaliar-rag-um-trace-por-requisi%C3%A7%C3%A3o-e-um-conjunto-dourado) é o lugar de olhar isso.


4. O filtro é um quarto índice, e sem ele o filtro não existe

Aqui está a parte que ninguém configura e todo mundo precisa. Toda base real tem

filtro: o tenant, o período, o tipo de documento. E o filtro não é uma etapa depois da

busca — ele tem que ser avaliado dentro dela, no

índice de payload do Qdrant,

que é uma estrutura separada da busca vetorial.

Por que o filtro tem índice próprio? Porque sem ele, para achar os 10 resultados que

passam pelo filtro, o motor precisa olhar muito mais que 10 vetores — precisa olhar

todos os que passam, e o filtro vira uma varredura. A ordem errada não é só ineficiente:

ela muda o resultado. A [documentação de indexação do

Qdrant](https://qdrant.tech/documentation/concepts/indexing/) separa as duas coisas — o

índice vetorial e o índice de payload,

que é o filtro — e o código abaixo monta exatamente a situação:

CHUNKS = [
    (f"alto{i}", "empresa-b", fake_vector("vibracao limite 4,5 parar equipamento " * 4))
    for i in range(18)
] + [
    (f"baixo{i}", "empresa-a", fake_vector("temperatura graus reduzir carga motor"))
    for i in range(12)
]
k = 5

# ERRADO: ordena o acervo inteiro, depois descarta o que não passa no filtro.
top_sem_filtro = [c for c, _, _ in CHUNKS[:k]]
depois = [c for c, t, _ in CHUNKS[:k] if t == "empresa-a"]
# CERTO: restringe o acervo ANTES de ordenar, e ordena só o que sobrou.
antes = [c for c, t, _ in CHUNKS if t == "empresa-a"][:k]

print(f"filtro DEPOIS -> {depois} ({len(depois)} de {k})")
print(f"filtro ANTES  -> {antes} ({len(antes)} de {k})")
filtro DEPOIS -> [] (0 de 5)
filtro ANTES  -> ['baixo0', 'baixo1', 'baixo2', 'baixo3', 'baixo4'] (5 de 5)

A busca devolveu zero resultados para um usuário que tem 12 trechos. A empresa tem

documento, o filtro está no código, e o usuário recebe "não encontrei". Esse é o

sintoma de filtro aplicado tarde. E repare que ele se parece muito com "o acervo não

tem esse documento" — que é o diagnóstico que todo mundo dá primeiro, e que está errado.

Esse DEPOIS é exatamente o buscar_errado do

artigo 01. Aqui o que está em jogo não é vazar

documento de outro tenant, é devolver pouco: o filtro é o que garante que o top-10

que você entrega ao modelo é o top-10 daquele escopo, e não o top-10 geral com alguns

resultados jogados fora.

No Qdrant, a API deixa isso

explícito, e a pegadinha é o nome do argumento:

def buscar_denso(client, collection: str, vector: list[float], filtro, k: int = 10):
    """Consulta densa pura, com o filtro dentro da busca.

    `filtro` é a estrutura de filtro da biblioteca. Este artigo não importa o
    Qdrant Client de propósito: a assinatura abaixo é a que está documentada,
    mas a construção do filtro (campo, operador, valor) é uma classe da
    biblioteca e você monta conforme a documentação.
    """
    from qdrant_client import QdrantClient, models  # noqa: PLC0415

    return client.query_points(
        collection_name=collection,
        query=vector,
        limit=k,
        # Sem `prefetch`, o filtro vai no topo, e o nome do argumento é
        # `query_filter` — não é `filter`.
        query_filter=filtro,
    )

Quando a busca for híbrida, com prefetch, o mesmo filtro muda de lugar: ele vai

dentro de cada Prefetch. Isso é assunto do

artigo 04, e é uma das pegadinhas que custam uma tarde

quando você não sabe disso.


5. Calcular similaridade no SQL, quando o banco já existe

Nem toda base precisa de um servidor de vetor. Se você já tem PostgreSQL rodando, a

resposta mais barata costuma ser uma extensão — e o motivo de existir um embedding como

número é exatamente esse: ele cabe numa coluna.

No SQLite (que já está na biblioteca padrão do Python) o caminho é o mais direto que

existe. O vetor é gravado como BLOB de float32 e desempacotado na leitura:

import sqlite3
import struct

con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE chunks (id TEXT PRIMARY KEY, tenant TEXT, vec BLOB)")
for nome, texto in TEXTO.items():
    v = fake_vector(texto)
    # float32 little-endian: metade do tamanho de float64, e a precisão que
    # a busca vetorial usa na prática.
    con.execute(
        "INSERT INTO chunks VALUES (?,?,?)",
        (nome, "empresa-b", struct.pack(f"<{len(v)}f", *v)),
    )
    con.commit()

def buscar_no_sqlite(con, tenant: str, consulta: str, k: int = 10):
    """Busca vetorial dentro do SQLite, com filtro na mesma consulta."""
    q = fake_vector(consulta)
    # `WHERE tenant = ?` roda DENTRO do banco, no mesmo passo da comparação.
    # O filtro e a busca são uma coisa só.
    linhas = con.execute(
        "SELECT id, vec FROM chunks WHERE tenant = ?", (tenant,)
    ).fetchall()
    notas = [
        (round(cosine(q, list(struct.unpack(f"<{len(q)}f", blob))), 3), cid)
        for cid, blob in linhas
    ]
    notas.sort(key=lambda x: -x[0])
    return notas[:k]

print(buscar_no_sqlite(con, "empresa-b", PERGUNTA))
[(0.338, 'curto_exato'), (0.252, 'longo_ruim'), (0.0, 'temperatura'), (0.0, 'ruido')]

Repare que o filtro entrou na cláusula WHERE e a comparação acontece em Python, sobre

o BLOB desempacotado. Isso é uma busca exata que roda dentro do seu banco, sem

nenhum servidor novo. Em PostgreSQL dá para fazer o mesmo, e a extensão pgvector

vai além: ela define o tipo vector, cria índices hnsw e ivfflat e oferece operadores

de distância em SQL puro. O caminho em SQL puro é o do pgvector mesmo; o caminho

"desempacote na aplicação" é o do SQLite e funciona em qualquer banco que tenha coluna

binária.

Esse caminho tem um limite que vale registrar: a comparação acontece linha por linha,

em interpretador, sem índice vetorial. Serve para base pequena, para protótipo e para

caso em que a pergunta "vou ter que operar um segundo banco?" é mais cara que a

pergunta "essa busca vai ficar lenta?". Em algum ponto ela fica. Aí você passa a usar

índice, e a extensão do banco passa a ser a melhor das duas opções.


6. Os quatro motores, e o que cada um é de fato

Quatro nomes aparecem em toda conversa de RAG. Eles não são quatro toques de "quase

igual": são quatro decisões de arquitetura diferentes, escondidas atrás do nome.

Qdrant é servidor dedicado. Roda como processo, guarda em disco, e foi desenhado em

volta à busca vetorial: índice HNSW, índice de payload para filtro, e busca híbrida

(que junta vetor denso e vetor esparso, o assunto do artigo 04). É o que você escolhe

quando a base é grande, o filtro é obrigatório e a busca é o sistema.

Chroma é biblioteca, não servidor. Ela roda dentro do seu processo, com um cliente

persistente local e a opção de um servidor separado — a [documentação

dele](https://docs.trychroma.com/) é o ponto de partida. O que você ganha é não ter

nada para instalar e uma API que pega rápido; o que você não tem é o controle do tipo de

índice e da métrica na configuração. É uma escolha de fase de projeto, não de destino.

pgvector é extensão do PostgreSQL. Você mantém um banco só: as linhas do seu

documento, o texto, o tenant e o vetor na mesma tabela, na mesma transação, no mesmo

backup. O filtro é SQL normal e usa os índices que você já tem. É a escolha certa quando

o vetor é um campo entre outros e não o centro do sistema.

sqlite-vec é extensão do SQLite, e o projeto

faz exatamente o que o nome diz. Mesmo espírito do pgvector, ainda mais leve: um

arquivo, nenhum processo, nenhum servidor. A comparação roda dentro do próprio SQLite.

É a escolha para base pequena, para aplicação local, para o que roda ao lado do usuário.

Agora a comparação, que só faz sentido depois das quatro explicadas acima:

O que éOnde o filtro rodaÍndice vetorialQuando escolher
Qdrantservidor dedicadoíndice de payload, dentro da buscaHNSW, mais e menos exatobase grande, filtro obrigatório, busca é o sistema
Chromabiblioteca no processofiltro da bibliotecaimplementação internaprotótipo, base pequena, zero operação
pgvectorextensão do PostgreSQLWHERE na mesma consulta, com índice auxiliarHNSW e IVFFlatvocê já tem PostgreSQL e quer um banco só
sqlite-vecextensão do SQLiteWHERE na mesma consultaexato por padrãobase pequena, arquivo único, aplicação local

A ordem de escolha, na prática, é esta:

  1. Você já tem algum banco com os outros dados? Se sim, extensão nele

    (pgvector ou sqlite-vec). Um banco a menos para operar, um backup a menos, uma

    transação a menos para raciocinar.

  2. A base é grande ou o filtro é o sistema? Se sim, servidor dedicado (Qdrant).

  3. Ainda é fase de projeto? Então comece pelo mais simples que roda, meça, e só

    depois troque. Trocar de motor é mais barato que trocar de modelo, porque não exige

    reindexar.


7. Trocar o modelo é reindexar tudo

A última coisa que este artigo precisa dizer é a mais cara. Modelo de embedding não é

parâmetro: é a função que transforma texto em número. Trocar o modelo muda o

significado de todos os números que já estão no banco, e eles passam a ser

incomparáveis com os novos.

Não há como converter. Não há como salvar. Se você tem 500 mil trechos indexados e troca

o modelo, o que você tem que fazer é: percorrer os 500 mil trechos, chamar o modelo novo

em cada um, gravar o vetor novo, e só então trocar a versão da consulta. 500 mil chamadas de vetorização é o preço, e ele não depende do tamanho do texto — depende

do número de trechos.

O que dá para fazer é não pagar esse preço duas vezes, e a forma é registrar a

versão junto com o dado:

from enum import StrEnum


class ScoreSource(StrEnum):
    """De onde veio o número da busca."""

    VECTOR = "vector"
    LEXICAL = "bm25"
    GRAPH = "graph"
    COMMUNITY = "community"


from pydantic import BaseModel, ConfigDict, Field
from typing import Any


class Contract(BaseModel):
    model_config = ConfigDict(extra="forbid", frozen=True)


class Chunk(Contract):
    """O trecho indexado. É a unidade que carrega o vetor."""

    chunk_id: str
    doc_id: str
    parent_id: str | None
    ordinal: int
    text: str
    token_count: int
    metadata: dict[str, Any] = Field(default_factory=dict)


class Scored(Contract):
    """Um resultado de recuperação, com a origem do score."""

    ref_id: str
    score: float
    source: ScoreSource
    payload: dict[str, Any] = Field(default_factory=dict)


class Collection:
    """O registro do que a coleção foi construída com.

    Guardar modelo, dimensão e métrica junto com o dado indexado é o que
    permite responder "o que está gravado aqui foi gerado pelo modelo que a
    consulta espera?" sem abrir todos os vetores.
    """

    def __init__(self, name: str, model: str, dim: int, metric: str) -> None:
        self.name = name
        self.model = model
        self.dim = dim
        self.metric = metric

    def aceita(self, vector: Sequence[float]) -> bool:
        return len(vector) == self.dim

    def precisa_reindexar(self, model: str, dim: int) -> bool:
        return (self.model, self.dim) != (model, dim)


c = Collection("docs", "modelo-a", 768, "cosine")
print(c.aceita([0.0] * 512), c.precisa_reindexar("modelo-a", 768), c.precisa_reindexar("modelo-b", 1024))
False False True

Três coisas nessa linha, e as três são decisões:

A dimensão é o detetor de erro mais barato que existe. dim=768 e dim=1024 não

conversam: comparar vetores de tamanhos diferentes não dá resultado, dá exceção ou

lixo. Se a coleção valida a dimensão na gravação e na consulta, o erro de modelo trocado

sem reindexar aparece como erro de validação, na hora, com nome de campo. Se não

valida, aparece como "a busca ficou ruim" semanas depois.

A métrica também precisa de registro. Um banco onde a métrica é padrão esconde o

parâmetro. Quando o padrão mudar, ninguém percebe. Registrando, a troca é uma

comparação de string.

A versão do modelo é o que o seu artigo 11 observa. O sintoma "a busca piora sozinha depois de um deploy" quase nunca é o

deploy: é a coleção com o modelo antigo e a consulta com o novo.

💡 Indexe em coleção dupla, não em coleção mutante
A forma barata de trocar de modelo é criar a segunda coleção com o modelo novo,

popular em paralelo, e só depois apontar a consulta para ela. Trocar no lugar

significa que existe um instante em que a base tem metade dos vetores no modelo

antigo e metade no novo — e nesse instante a busca devolve resultado misturado sem

nenhum erro. Duas coleções custam o dobro de espaço durante a troca; coleção

mutante custa um resultado errado que ninguém percebe.


TL;DR

  • A escolha são três variáveis: modelo, métrica e índice. A ordem de importância é

    métrica, índice, modelo — e quase todo mundo mexe só na terceira.

  • A métrica define o ranking antes de o índice existir. No mesmo acervo, cosseno,

    produto interno e euclidiana deram três ordens diferentes: pelo produto interno, uma

    folha de ponto sem informação ganhou o primeiro lugar; pela euclidiana, ela ficou por

    último. Normalize na gravação.

  • Índice aproximado troca acerto por tempo, e a troca é um parâmetro. No exemplo,

    sondar uma lista leu 8 vezes menos vetores e devolveu metade da resposta. Abaixo de

    algumas dezenas de milhares de pontos, comece com busca exata: ela costuma ganhar em

    acerto e em latência.

  • Filtro sem índice é filtro tarde, e filtro tarde muda o resultado. A ordem errada

    devolveu 0 resultados para um usuário que tinha 12 trechos no escopo. O sintoma é

    "não encontrei", que é o diagnóstico errado.

  • Similaridade no SQL é uma saída real quando o vetor é um campo entre outros: o

    vetor como BLOB/BYTEA de float32, desempacotado na leitura, filtro na mesma

    consulta. Sem índice vetorial, e isso tem limite.

  • Trocar de modelo é reindexar tudo. Registre modelo, dimensão e métrica junto com o

    dado, e troque criando uma coleção nova em vez de mutando a existente.


Referências

  • Indexing — Qdrant — índice vetorial e índice de payload, o par que a seção 4 separa
  • Filtering — Qdrant — o filtro por metadado como índice, e o que acontece sem ele
  • Hybrid Queries — Qdrant — onde o filtro muda de lugar quando há prefetch, que o artigo 04 detalha
  • pgvector — extensão do PostgreSQL: tipo vector, índices HNSW e IVFFlat, operadores de distância em SQL
  • sqlite-vec — extensão de vetores para o SQLite, o caminho em BLOB da seção 5
  • Chroma — a biblioteca que roda no processo, para o leitor decidir entre ela e um servidor dedicado
  • MTEB: Massive Text Embedding Benchmark — a métrica de recuperação publicada que é a base honesta para escolher modelo
  • DiskANN: Fast Accurate Billion-point Nearest Neighbor Search — o trabalho que mostra HNSW funcionando em escala de bilhões de pontos