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ável | A pergunta que ela responde | Onde se decide |
|---|---|---|
| Modelo | o que cada vetor significa | no carregador, fora do banco |
| Métrica | o que "parecido" quer dizer | na criação da coleção |
| Índice | como comparar um milhão de vetores rápido | na 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 documentovolta, 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 seestá 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
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 vetorial | Quando escolher | |
|---|---|---|---|---|
| Qdrant | servidor dedicado | índice de payload, dentro da busca | HNSW, mais e menos exato | base grande, filtro obrigatório, busca é o sistema |
| Chroma | biblioteca no processo | filtro da biblioteca | implementação interna | protótipo, base pequena, zero operação |
| pgvector | extensão do PostgreSQL | WHERE na mesma consulta, com índice auxiliar | HNSW e IVFFlat | você já tem PostgreSQL e quer um banco só |
| sqlite-vec | extensão do SQLite | WHERE na mesma consulta | exato por padrão | base pequena, arquivo único, aplicação local |
A ordem de escolha, na prática, é esta:
-
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.
-
A base é grande ou o filtro é o sistema? Se sim, servidor dedicado (Qdrant).
-
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/BYTEAde float32, desempacotado na leitura, filtro na mesmaconsulta. 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
BLOBda 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