Busca vetorial: achando vizinhos entre milhões de vetores
Como achar os vetores mais próximos de uma consulta quando existem milhões deles, com índices aproximados como IVF, HNSW e quantização.
Publicada em · 46 min de leitura
Esta aula trata de achar os vizinhos mais próximos entre milhões de vetores sem comparar a consulta com todos eles. Para isso entram os índices aproximados, como IVF e HNSW, e a quantização. Comparar uma consulta com todos os vetores custa N × 384 multiplicações, e isso deixa de caber no orçamento quando N chega a dezenas de milhões ou quando as consultas chegam aos milhares por segundo. Ao terminar, você deve entender por que a busca exata deixa de caber no orçamento, como funcionam os índices, o que é recall e como ele se troca por velocidade, e por que um índice sozinho não basta para um sistema em produção.
A aula parte do zero, mas pressupõe o que é um embedding e como medir a distância entre dois vetores. Cada técnica vem primeiro numa analogia, depois no mecanismo, depois em números. Os números de recall, tempo e memória que aparecem foram medidos por mim, num MacBook com chip M1 Max, com a biblioteca FAISS e com a hnswlib, sobre vetores sintéticos com estrutura de grupos (a seção 12 mostra o código e a seção de fontes lista o que não consegui verificar). Tudo o que muda com o tempo, como valores padrão de parâmetros, vem da documentação oficial consultada em 15 de outubro de 2026. As figuras são animadas e foram geradas com o ArchFlow.
Mapa da aula
| Parte | Seções | Pergunta que responde |
|---|---|---|
| 1. O problema | 1 a 4 | O que é a busca por vizinhos, por que a busca exata não escala e o que significa aproximar? |
| 2. Três famílias de índice | 5 a 8 | Como o IVF, o HNSW e a quantização evitam comparar com todos? |
| 3. Na prática | 9 a 12 | Como os três se comparam em números, quais parâmetros ajustar, onde a técnica falha e como se faz em código? |
| 4. Fechamento e prática | 13 a 15 | Que erros evitar, o que mais existe, como treinar e que palavras guardar? |
Pré-requisitos: Embeddings e Similaridade entre vetores. Os diagramas seguem um mapa de cores fixo: laranja para a consulta (o que a pessoa pergunta), azul para vetores, índices e contas, verde para resultados bons e documentos, cinza para o que é neutro e rosa para o que dá errado ou custa caro. O roxo aparece uma vez, na figura 12, para marcar a latência.
Parte 1: O problema
1. Achar o livro certo sem ler todas as lombadas
Imagine uma biblioteca com um milhão de livros e um catálogo que só sabe uma coisa: dizer, para cada livro, o quanto ele se parece com o pedido que você fez. Para achar os dez livros mais parecidos com “histórias de navegação no século 19”, o bibliotecário seguiria o catálogo livro por livro, do primeiro ao último, anotando os dez melhores. Funciona, e o resultado é perfeito, mas leva a tarde inteira. Agora imagine outra biblioteca, em que os livros estão em estantes por assunto, as estantes têm placas e um mapa na entrada mostra quais estantes ficam perto de quais. O bibliotecário olha o mapa, vai direto a duas ou três estantes e examina só esses livros. Ele pode deixar escapar um bom livro que ficou numa estante vizinha, mas volta em um minuto.
Esse é o assunto da aula. A busca vetorial (também chamada busca por vizinhos mais próximos, ou nearest neighbor search) é o problema de, dado um vetor de consulta e um acervo de vetores, devolver os k vetores do acervo mais parecidos com a consulta, na medida de distância vista em Similaridade entre vetores. A “estante com placa e mapa” é o que a área chama de índice: uma estrutura montada de antemão, que custa espaço e tempo para construir, e que torna cada consulta muito mais barata. A palavra “aproximada” entra porque quase todo índice que escala troca uma parte da exatidão por velocidade, e saber medir essa troca é metade do ofício.
Para que isso importa num RAG: o passo de busca do pipeline roda a cada pergunta do usuário. Se a busca leva quatro segundos, o chat inteiro fica lento. Se a busca é rápida mas devolve os trechos errados, o modelo responde mal. O índice é o lugar onde essas duas pressões se encontram.
2. Busca exata: a conta que sempre funciona
O jeito mais simples de achar os vizinhos mais próximos é a força bruta: calcular a similaridade entre a consulta e cada vetor do acervo e ficar com os k maiores. Quando o resultado é exato, o problema se chama k-NN (k nearest neighbors, os k vizinhos mais próximos). A força bruta tem duas qualidades difíceis de superar: não precisa de preparo nenhum (você joga os vetores numa matriz e multiplica) e sempre devolve a resposta certa. Por isso ela é também a régua com que se mede qualquer índice: a “verdade” a que os índices aproximados serão comparados.
O custo foi medido em Similaridade entre vetores: para vetores de 384 números em float32, um milhão de vetores levou cerca de 40 ms por consulta com numpy, e o custo cresce de forma linear com o número de vetores. Quarenta milissegundos é aceitável para uma pergunta de chat. O problema aparece em três cenas diferentes. Num atendimento de suporte com 50 mil trechos, a força bruta custa uma fração de milissegundo e é a escolha certa. Num marketplace com 20 milhões de produtos buscados por imagem e texto, cada consulta custaria cerca de 0,8 segundo se o custo seguisse linear (conta minha, extrapolando os 40 ms por milhão) e os vetores ocupariam algo como 30 GB, o que já pesa. Numa busca em um bilhão de vetores, a conta deixa de ser razoável em qualquer máquina única: seriam mais de 1 TB só de números. O mesmo vale para o volume de consultas: mil consultas por segundo contra um milhão de vetores exigiriam quarenta segundos de cálculo a cada segundo de relógio.
A figura coloca a mesma consulta nos dois mundos. À esquerda, no grupo “Exata”, a consulta laranja sai em seis setas lentas, uma para cada trecho, e todas as comparações alimentam o top-k exato. À direita, no grupo “Com índice”, o mesmo vetor chega ao retângulo azul pulsante do índice, que aponta só para os trechos 1 e 2 (setas grossas e rápidas). Os trechos 3 a 6 aparecem tracejados e “nunca visitados”: ninguém os comparou, e é daí que vem a economia. O risco também está no desenho: se um dos trechos não visitados fosse o melhor, o índice não o acharia, e a seção 4 dá nome a esse risco.
3. Por que um índice comum não resolve
Quem conhece bancos de dados vai pensar: para que inventar outra coisa, se a B-Tree já faz busca rápida em milhões de linhas? A B-Tree funciona porque as chaves têm uma ordem. Quando você procura preço = 42, a raiz da árvore diz que 42 está entre 10 e 50, o nó seguinte diz que está entre 40 e 50, a folha contém o 42, e três leituras bastam entre milhões de linhas. Cada comparação descarta uma enorme fatia do acervo, e isso só é possível porque “menor que” e “maior que” particionam os dados de forma limpa.
Com vetores não existe essa ordem. Seria tentador ordenar os vetores pela primeira dimensão e procurar por ali, mas dois vetores vizinhos na dimensão 1 podem estar a quilômetros um do outro na dimensão 2, e na 3, e assim por diante. Cada uma das 384 dimensões pede uma ordenação diferente, e elas se contradizem. A “proximidade” de que a busca precisa é uma propriedade conjunta de todas as dimensões, e nenhuma ordem linear a respeita. Uma seção de Similaridade entre vetores mostrou um agravante: com muitas dimensões, as distâncias de uma consulta a pontos aleatórios se concentram, e o vizinho mais próximo fica quase tão longe quanto o mais distante, o que enfraquece qualquer estratégia que tente descartar regiões inteiras com certeza.
À esquerda, a B-Tree: a consulta laranja desce por três nós azuis, com o rótulo de cada passo (“42 < 50”, “42 > 40”) mostrando por que um ramo inteiro é descartado, e chega ao documento verde. Os pacotes são rápidos porque o caminho é curto. À direita, o mesmo esforço com vetores: o primeiro passo parece promissor (ordenar pela dimensão 1 põe A e B lado a lado), mas o retângulo rosa seguinte mostra que na dimensão 2 eles estão em extremos opostos. As setas rosas e lentas levam ao retângulo “Cada dimensão pede uma ordem”, e a última seta é barrada com ✕: não há atalho de ordem, e o que sobra é o documento tracejado “Varrer todos”. Os índices vetoriais a seguir são as respostas inventadas para esse impasse, e nenhum deles se apoia numa ordem.
4. Aproximar de propósito, e medir o que se perde
Se não há como descartar com certeza, a saída é descartar com boa chance: visitar só as partes do acervo onde os vizinhos provavelmente estão e aceitar que, de vez em quando, um vizinho verdadeiro fique de fora. Essa família se chama ANN (approximate nearest neighbor, vizinho mais próximo aproximado). Para saber se a aproximação é boa precisamos de uma medida, e a que a área usa é o recall.
O recall@k responde a esta pergunta: dos k vizinhos verdadeiros (os que a busca exata devolveria), quantos o índice também devolveu? Um exemplo pequeno, com k = 5. A busca exata devolve A, B, C, D e E. O índice devolve A, B, C, D e F, onde F era na verdade o sexto colocado. Quatro dos cinco verdadeiros vieram, e o recall@5 é 4 ÷ 5 = 0,8. Recall 1,0 significa que o índice devolveu exatamente os mesmos k vizinhos da busca exata (a ordem entre eles é outra questão). Note que a conta usa a busca exata como gabarito, então ela mede o quanto o índice se parece com a força bruta e não o quanto os trechos são úteis para a pergunta.
No topo, a consulta laranja pede k = 5. Ela alimenta dois retângulos azuis: a busca exata, de pacotes lentos (compara com todos), e a aproximada, de pacotes rápidos e retângulo pulsante (usa o índice). Cada uma entrega cinco documentos. No grupo “Verdade”, A a D são verdes e o E é rosa tracejado, o vizinho que o índice perdeu. No grupo “Aproximada”, A a D são verdes e o F, cinza tracejado, é o intruso. Os quatro acertos seguem em setas verdes para o retângulo do recall; o intruso é barrado com ✕ (“intruso não conta”), e o E, rosa tracejado, fica sem seta nenhuma: ninguém o devolveu. O número final, 0,8, é o resumo do que a aproximação custou nesta consulta.
Duas ressalvas guiam o resto da aula. A primeira: o recall se mede sobre muitas consultas, e o valor que se reporta é a média, porque uma consulta isolada diz pouco. A segunda: um recall de 0,95 não quer dizer que 5% das respostas do seu RAG ficam erradas. Os vizinhos perdidos costumam ser os últimos da lista (os k-ésimos, de similaridade quase igual à do que entrou no lugar), e o Reranking ainda reordena os candidatos adiante. Mesmo assim, a qualidade final só se confirma medindo o sistema inteiro. O recall mede o índice, e a avaliação mede o sistema.
Parte 2: Três famílias de índice
5. O mapa: menos candidatos, menos passos, menos bytes
Quase todo índice vetorial usado hoje cai em uma de três ideias, e vale ter o mapa antes de abrir cada uma. A primeira é particionar: agrupar os vetores por vizinhança de antemão e, na consulta, olhar só os grupos mais próximos. Essa é a ideia do IVF (inverted file index, índice de arquivo invertido). A segunda é navegar: ligar cada vetor aos seus vizinhos num grafo e, na consulta, andar pelo grafo na direção da consulta, como quem pede indicação de rua em rua. É o HNSW (hierarchical navigable small world, mundo pequeno navegável hierárquico). A terceira é comprimir: guardar de cada vetor um código curto que permite estimar distâncias com pouca memória, e é a quantização, de que a PQ (product quantization, quantização por produto) é a forma mais conhecida. As duas primeiras reduzem quantas contas se fazem. A terceira reduz o tamanho de cada conta e o espaço ocupado, e por isso costuma ser combinada com uma das outras.
O acervo azul alimenta três estratégias em paralelo, e cada uma carrega uma etiqueta com o que economiza: menos candidatos (IVF), menos passos (HNSW) e menos bytes (PQ). Abaixo de cada uma, a seta pontilhada leva a um retângulo rosa com o preço que ela cobra: treino e vizinhos fora da lista visitada, memória do grafo e construção lenta, distâncias aproximadas. As três setas verdes e rosadas convergem para o mesmo resultado, o top-k aproximado, cuja descrição (“recall menor que 1”) repete o aviso da seção anterior. Guarde a figura como o sumário das três seções seguintes.
6. IVF: dividir o acervo em estantes
A analogia é a das estantes por assunto. Para criar as estantes, o IVF roda um algoritmo de agrupamento chamado k-means sobre o acervo (ou sobre uma amostra dele). O k-means começa com k pontos escolhidos e repete duas ações: associar cada vetor ao ponto mais próximo e mover cada ponto para o centro (a média) dos vetores que lhe couberam. Depois de algumas rodadas, os pontos assentam nos centros dos agrupamentos naturais dos dados. Esses pontos são chamados de centroides. Cada vetor do acervo é então guardado na lista do centroide mais próximo dele, e o conjunto de listas é a estrutura que dá nome ao índice: um arquivo invertido, que mapeia “centroide → vetores que moram nele”. O número de listas se chama nlist.
A figura percorre a construção. O acervo (100.000 vetores de 384 números, o tamanho que uso nas medições da seção 9) entrega uma amostra ao k-means, marcado como etapa de treino e girando porque é o processamento pesado. O k-means produz os centroides (mostro quatro dos 1.024), e cada centroide aponta para a sua lista, representada pelos cilindros verdes de cerca de 98 vetores (100.000 ÷ 1.024 ≈ 98, se os vetores se dividissem por igual, o que na prática não acontece). Tudo isso é azul porque é estrutura que o sistema monta sozinho, e verde onde há vetores guardados.
Na consulta, o IVF faz o caminho inverso e mais curto. Compara a consulta com os nlist centroides (1.024 contas), escolhe os nprobe centroides mais próximos (nprobe é “quantas listas visitar”) e só então compara a consulta com os vetores dessas listas. Com nlist = 1.024 e nprobe = 2, a busca examina 1.024 centroides mais cerca de 196 vetores, perto de 1.200 contas em lugar de 100.000. Os dois parâmetros puxam para lados opostos. Mais listas deixam cada lista menor, mas aumentam o custo de comparar com os centroides. Por isso as documentações sugerem escalar nlist com a raiz do número de vetores: o FAISS indica algo perto de C × sqrt(n), com C em torno de 10 como exemplo, e o pgvector recomenda rows / 1000 até um milhão de linhas e sqrt(rows) acima disso.
A consulta laranja compara-se aos centroides (1.024 contas, não 100.000) e chega ao losango “nprobe = 2”. As duas listas mais próximas, Lista 1 e Lista 2, recebem setas azuis e grossas e são varridas (98 comparações cada), e o resultado verde sai de apenas 196 vetores. A Lista 4 fica de fora sem perda, longe e pontilhada. A Lista 3 é a falha do método: ela era a terceira mais próxima, a seta é barrada com ✕, e ela guardava um dos vizinhos verdadeiros. É o caso que o FAISS descreve: a busca falha quando a lista do vizinho mais próximo não está entre as visitadas. Acontece com vetores que moram perto da fronteira entre dois agrupamentos, onde “o centroide mais próximo” é uma decisão apertada.
Duas consequências práticas. Primeiro, o IVF precisa de treino: o k-means precisa ver dados reais, e o pgvector avisa que a criação do índice IVFFlat deve ocorrer depois de a tabela ter dados, porque listas demais para poucas linhas dão resultados ruins. Segundo, nprobe é um botão de consulta, ajustável sem reconstruir o índice (o FAISS destaca isso): quanto maior, mais recall e mais lento. No pgvector, probes vale 1 por padrão, a sugestão inicial é sqrt(lists), e probes igual ao número de listas equivale a busca exata. Um aviso do conhecimento geral da área, sem fonte consultada hoje: quando o acervo cresce muito ou muda de assunto, os centroides ficam desatualizados e o recall cai, e a correção é retreinar.
7. HNSW: navegar por um grafo de vizinhos
O HNSW parte de uma observação sobre redes: em muitos grafos, dois nós quaisquer ficam a poucos saltos de distância (a ideia dos “seis graus de separação” entre pessoas). Um grafo desse tipo é chamado de mundo pequeno. Se cada vetor do acervo for um nó ligado aos seus vizinhos mais próximos, dá para achar o vizinho da consulta com uma busca gulosa: começa-se num nó qualquer, olha-se quem são os vizinhos dele, passa-se ao que está mais perto da consulta, e repete-se até nenhum vizinho estar mais perto que o nó atual. Só que, num grafo de vizinhos próximos, cada salto é curto, e atravessar o acervo por saltos curtos leva muitos passos.
A segunda ideia resolve isso e dá o H do nome: camadas. Pense no zoom de um mapa. De avião você vê poucas cidades grandes e as estradas longas entre elas, e vai de um estado a outro em poucos saltos. Aproximando, aparecem as cidades médias. No nível da rua, aparecem todas as casas. O HNSW constrói o mesmo: a camada 0 contém todos os vetores, ligados aos vizinhos próximos. A camada 1 contém uma amostra deles, a camada 2 uma amostra menor, e assim por diante, e cada vetor sorteia, ao ser inserido, até que camada ele sobe (a probabilidade cai a cada nível). Os autores do artigo original (Malkov e Yashunin, 2016) afirmam que essa estrutura dá escala logarítmica, isto é, o custo da busca cresce muito devagar com o número de vetores.
Leia de cima para baixo. A consulta laranja entra pelo topo, no nó A da camada 2, que tem só dois nós ligados por um salto longo. A seta grossa vai até F (“F está mais perto”) e depois desce para o F da camada 1. Na camada 1 não existe vizinho melhor (as linhas finas ligam A, C, F e H), então a busca desce de novo, para o F da camada 0, onde estão todos os vetores. Ali basta um passo curto até o G, que pulsa em verde: é o vizinho mais próximo, entregue ao retângulo de resultado. O desenho mostra quatro saltos entre nós para um acervo de oito vetores, o que parece pouco ganho, mas vale lembrar que em um acervo de milhões a camada 0 é enorme, e o que se economiza é percorrê-la desde a borda.
A camada 0 é onde mora a precisão, e lá a busca gulosa pura costuma ficar presa num mínimo local (um nó cujos vizinhos estão todos mais longe, embora exista um vizinho melhor em outra região do grafo). O HNSW evita isso mantendo uma lista de candidatos em vez de seguir um único caminho, e o tamanho dessa lista é o parâmetro ef (de “exploration factor”; no FAISS chama-se efSearch, no pgvector hnsw.ef_search).
A figura é o zoom na camada 0, lida de cima para baixo. O ponto de entrada (vindo da descida) vai para a fila de candidatos. O laço é: pegar o candidato mais perto da consulta, olhar os M vizinhos dele (o retângulo de baixo, “Pontua os vizinhos novos”, faz uma conta de similaridade por vizinho), pular quem já foi visitado (a seta cinza pontilhada vem do retângulo “Já visitados”) e deixar entrar na lista “Melhores até agora” quem for melhor que o pior dela. A lista verde tem tamanho ef, aqui 64. Depois, o losango decide: se o candidato mais perto da fila já é pior que o pior da lista, não há mais o que ganhar e a busca para (“sim”, seta verde grossa, devolve os k melhores). Se não, volta ao topo para outra rodada (a seta laranja e lenta para o retângulo tracejado). Um ef maior mantém a busca viva por mais rodadas e examina mais nós: mais recall, mais tempo. Como a lista final precisa ter ao menos k itens, o ef não pode ser menor que k (a documentação da hnswlib diz isso com todas as letras).
O HNSW tem três botões. O M é o número de vizinhos que cada nó mantém. Mais vizinhos dão mais caminhos e mais recall, ao custo de mais memória e de uma construção mais lenta. A hnswlib estima a memória em M × 8 a 10 bytes por vetor, recomenda valores de 12 a 48 para a maioria dos casos e valores maiores (48 a 64) para embeddings de palavras. O efConstruction é o equivalente do ef durante a construção: mais alto, o grafo sai melhor e a construção demora mais (o pgvector avisa que isso também desacelera as inserções). O ef de busca é o botão da consulta. Os valores padrão que li: pgvector m = 16, ef_construction = 64, hnsw.ef_search = 40; Qdrant m = 16 e ef_construct = 100. O pgvector avisa ainda que o HNSW “tem tempos de construção maiores e usa mais memória” que o IVFFlat, e que, por não ter treino, pode ser criado numa tabela vazia. Um ponto de atenção do conhecimento geral da área (não vi isso nas páginas consultadas hoje): remover vetores de um grafo HNSW costuma ser o ponto fraco, porque as arestas que apontavam para o vetor removido precisam ser reparadas, então confira como o seu motor trata remoções e atualizações.
8. Quantização: guardar menos por vetor
Até aqui, os vetores continuaram guardados inteiros: 384 números de 4 bytes cada, 1.536 bytes por vetor. A terceira ideia ataca esse tamanho. A quantização troca cada número, ou grupo de números, por um código mais curto de um repertório finito, e aceita a imprecisão. A versão mais simples é a quantização escalar: cada número de 32 bits passa a ocupar 8 bits (um inteiro), o que dá 4 vezes menos memória. O Qdrant cita compressão de cerca de 4 vezes e erro “geralmente menor que 1%”, dependendo dos dados. A quantização binária guarda só um bit por número (o sinal), com compressão de até 32 vezes, e o Qdrant a recomenda apenas com vetores de muitas dimensões e com rescore ligado. A quantização por produto (PQ), de Jégou, Douze e Schmid (2011), vai mais longe e é a mais interessante de entender.
A PQ divide o vetor em pedaços e quantiza cada pedaço separadamente (daí “produto”: o espaço é o produto cartesiano de subespaços pequenos). Com um vetor de 384 números e 48 pedaços, cada pedaço tem 8 números. Para cada posição de pedaço, o treino produz um dicionário de 256 centroides de 8 números (o k-means de novo, agora em pedaços). Para guardar um vetor, cada pedaço é substituído pelo número do centroide mais parecido, um valor entre 0 e 255 que cabe em 1 byte. O vetor inteiro vira um código de 48 bytes em vez de 1.536, uma redução de 32 vezes.
O vetor original azul (1.536 bytes) é cortado em pedaços (mostro o 1, o 2 e o 48 dos 48), cada pedaço consulta o dicionário treinado da sua posição (“mais perto?”) e recebe um número de centroide, os retângulos verdes de bytes. Os 48 bytes se juntam no código do vetor, que pulsa e carrega a etiqueta da conta, 1.536 ÷ 48 = 32. O retângulo rosa tracejado no fim é o aviso: cada pedaço virou o centroide mais parecido, então o vetor que se reconstrói a partir do código é aproximado. A busca com PQ trabalha com essas aproximações: pela técnica das distâncias assimétricas descrita no artigo (a consulta fica inteira e só os vetores do acervo são codificados), estima-se a distância de uma consulta a um código somando valores de tabelas, sem reconstruir o vetor. A PQ costuma aparecer junto do IVF (“IVF+PQ”), de modo que a varredura das listas lê códigos curtos em lugar de vetores inteiros, e o artigo de 2011 descreve justamente a combinação com um arquivo invertido, testada num acervo de dois bilhões de vetores.
O custo é o recall. Nas minhas medições da seção 9, o IVF com PQ de 48 bytes devolveu só 0,37 de recall, o que é um valor baixo (os vetores sintéticos que usei têm muito ruído, e PQ sofre com isso; em embeddings reais o resultado pode ser melhor, e eu não medi). A saída mais usada é o refino (rescore, em inglês): usar o código para escolher candidatos em abundância e depois reordená-los com a conta exata, que exige ter os vetores originais à mão (em RAM ou em disco).
Leia de cima para baixo. A consulta laranja entra no IVF (nprobe = 16), que lê os códigos PQ de 48 bytes (7,6 MB para 100 mil vetores, azul) e pontua os vetores das listas visitadas. Sozinha, essa pontuação acha 37% dos vizinhos verdadeiros (etiqueta amarela), mas é barata. Em vez de entregar o top-10, ela entrega 100 candidatos (dez vezes o k pedido). A seguir, o retângulo pulsante “Recalcula a conta exata” lê só esses 100 vetores originais (a seta verde e lenta, do cilindro de 153,6 MB) e os reordena. O recall sobe a 0,955, e o top-10 final sai em ordem correta. A economia está na asa direita da figura: o cálculo caro acontece em 100 vetores, e não em 100.000. O preço fica escondido no retângulo verde dos originais: o refino só é possível se eles continuarem guardados, e é por isso que a PQ com refino economiza tempo de busca mais do que memória total, a menos que os originais fiquem em disco. O Qdrant implementa essa ideia com os parâmetros de rescore e de oversampling (um oversampling de 2,4 com limite 100 pré-seleciona 240 candidatos e devolve os 100 melhores), e o pgvector mostra o padrão com a quantização binária: busca por Hamming para pegar candidatos e reordenação pela distância do cosseno original.
Parte 3: Na prática
9. O triângulo: recall, latência e memória
Os três índices têm uma coisa em comum: cada ganho vem com uma conta. Aumentar o recall custa latência (examinar mais candidatos) ou memória (um grafo com mais arestas). Reduzir memória custa recall (a PQ) ou latência (um refino com vetores em disco). Reduzir a latência custa recall (examinar menos). Não existe ponto em que os três melhoram juntos, e o trabalho do engenheiro é escolher o ponto que o produto aguenta.
Os três cantos da figura são as três grandezas: recall (verde, no topo), latência (roxo) e memória (cinza). Entre elas ficam os botões azuis, e cada seta rotulada diz o que o botão faz com cada grandeza. Subir ef ou nprobe (esquerda) aumenta o recall e a latência. Subir M ou nlist (direita) aumenta o recall e a memória (e a construção fica lenta). Comprimir com PQ (embaixo, à direita) reduz a memória, com a seta grossa verde, e perde recall, com a seta barrada por ✕. O refino exato (embaixo, à esquerda) recupera o recall perdido, com um pouco de latência a mais e a exigência de manter os originais. Nenhum botão mexe numa grandeza só: cada seta que melhora uma piora ou encarece outra.
Os números a seguir foram medidos por mim (código na seção 12). O acervo: 100.000 vetores sintéticos de 384 números, normalizados, divididos em 300 grupos com ruído; 100 consultas do mesmo tipo; k = 10; uma thread; um MacBook com M1 Max. A base de comparação é a busca exata do FAISS (IndexFlatIP), que levou 3,2 a 3,9 ms por consulta em duas execuções, com recall 1,0 por definição e 153,6 MB (100.000 × 384 × 4 bytes).
| Índice e ajuste | Recall@10 | Tempo por consulta | Observação |
|---|---|---|---|
| HNSW, ef = 8 | 0,626 | 0,05 ms | rápido e fraco |
| HNSW, ef = 16 | 0,828 | 0,07 ms | |
| HNSW, ef = 32 | 0,958 | 0,10 ms | |
| HNSW, ef = 64 | 0,996 | 0,15 ms | quase exato, 25 vezes mais rápido |
| HNSW, ef = 128 | 1,000 | 0,24 ms | |
| IVF, nprobe = 1 | 0,544 | 0,05 ms | uma lista só |
| IVF, nprobe = 2 | 0,818 | 0,05 ms | |
| IVF, nprobe = 4 | 0,979 | 0,06 ms | |
| IVF, nprobe = 16 | 1,000 | 0,12 ms | |
| IVF+PQ (48 bytes), nprobe = 16 | 0,366 | 0,11 ms | 7,6 MB no total |
| IVF+PQ (48 bytes) com refino de 100 candidatos | 0,955 | 0,15 ms | precisa dos originais |
O recall dos três grupos é idêntico nas duas execuções que fiz, e os tempos variaram em torno de 10 a 20%, de modo que leia-os como ordem de grandeza. Quanto à memória, o índice serializado teve 153,6 MB para o Flat, 168,0 MB para o HNSW com M = 16, 156,0 MB para o IVF e 7,6 MB para o IVF+PQ de 48 bytes (12,4 MB com 96 bytes, que levou o recall a 0,643 sem refino e a 1,000 com refino).
A figura reproduz a tabela como duas escadas. No topo, a busca exata (recall 1,000, 3,9 ms, 153,6 MB) é o ponto de partida. À esquerda, o HNSW sobe de ef = 8 a 128, e à direita o IVF sobe de nprobe = 1 a 16, sempre com as setas verdes e lentas de “mais esforço”. A cor de cada degrau codifica o recall: rosa abaixo de 0,9, azul entre 0,9 e 0,99 e verde a partir de 0,99. Repare em dois padrões. Primeiro, o ganho de recall se concentra nos primeiros degraus e depois satura, ao passo que o tempo continua subindo: no HNSW, ir de ef = 64 a 128 compra 0,004 de recall por 0,09 ms. Segundo, os dois índices chegam perto de 1,0 gastando de 25 a 70 vezes menos tempo que a força bruta neste acervo (HNSW com ef = 64 e IVF com nprobe = 4, respectivamente). Vale a ressalva de sempre: os vetores são sintéticos e com estrutura clara de grupos, o que favorece os dois índices.
10. Parâmetros práticos e como calibrar
Cada motor expõe os mesmos botões com nomes diferentes, e vale ter a tabela de tradução. Os valores abaixo são os que li nas documentações em 15 de outubro de 2026.
| O que ajusta | pgvector | Qdrant | hnswlib | FAISS |
|---|---|---|---|---|
| Vizinhos por nó (HNSW) | m (16) | m (16) | M (12 a 48 para a maioria dos casos) | M no IndexHNSWFlat |
| Esforço na construção | ef_construction (64) | ef_construct (100) | ef_construction | hnsw.efConstruction |
| Esforço na consulta | hnsw.ef_search (40) | ef (padrão igual ao ef_construct) | ef (no mínimo k) | hnsw.efSearch |
| Listas (IVF) | lists (rows ÷ 1000, ou raiz de rows acima de 1M) | não usa IVF | não usa | nlist (perto de C × raiz de n) |
| Listas visitadas | ivfflat.probes (1; começar com raiz de lists) | não usa | não usa | nprobe |
| Compressão | halfvec, binary_quantize | escalar, binária, PQ, com rescore e oversampling | não oferece | IndexIVFPQ, IndexScalarQuantizer |
Parâmetros de construção (m, ef_construction, lists) ficam gravados no índice e mudar um deles exige reconstruir. Parâmetros de consulta (ef, nprobe) podem mudar a cada consulta, o que os torna o primeiro lugar para ajustar quando o recall não satisfaz. Os padrões são pontos de partida e não receitas: o pgvector tem hnsw.ef_search = 40 e o exemplo que ele próprio dá, de busca com filtro, mostra que 40 pode ser pouco. Qual combinação serve ao seu acervo só se descobre medindo, e o procedimento é sempre o mesmo.
A figura é um roteiro. Do acervo azul sai uma amostra com vetores e consultas reais. A busca exata, devagar e executada uma única vez, produz a “verdade”: o top-10 exato de cada consulta. Em paralelo, as mesmas consultas laranja passam pelo índice aproximado. O retângulo azul “recall@10 e latência p95” compara as duas saídas e cronometra. O p95 é o tempo abaixo do qual ficam 95% das consultas, e importa mais que a média porque o usuário sente as lentas. O losango decide com metas (as da figura, recall maior ou igual a 0,95 e p95 menor ou igual a 20 ms, são de exemplo: defina as do seu produto). Se passar, guardam-se os parâmetros. Se não, ajusta-se um parâmetro por vez (seta rosa, lenta) e a medição recomeça no índice, nunca na verdade, que não muda.
Três cuidados tornam esse teste confiável. Use consultas que se pareçam com as de produção, de preferência reais: consultas escolhidas a dedo ou tiradas do próprio acervo superestimam o recall, como mostra a seção 11. Use acervo de tamanho próximo ao final, porque o recall e a latência dependem de N. E refaça a medição quando trocar de modelo de embedding, pois a estrutura dos vetores muda e os parâmetros ótimos mudam junto. A documentação da hnswlib dá uma dica prática para o ef_construction: busque os M vizinhos mais próximos com ef igual a ef_construction, e se o recall ficar abaixo de 0,9 ainda há espaço para melhorar.
11. Bordas: filtros, dados sem estrutura e atualizações
O primeiro limite aparece quando a consulta tem um filtro (por exemplo, “só documentos da categoria x” ou “só os do usuário 17”). A intuição manda filtrar e depois buscar, mas o índice é montado sobre todos os vetores, e o filtro costuma ser aplicado depois que o índice devolve os candidatos. O README do pgvector descreve o efeito com um exemplo: se a condição vale para 10% das linhas, o HNSW com hnsw.ef_search = 40 devolve em média cerca de 4 linhas, e não as 10 pedidas.
A consulta laranja pede dez linhas com filtro e entra no índice HNSW, que devolve os 40 candidatos mais próximos de qualquer categoria. O losango aplica o filtro: dos 40, só 10% passam, cerca de 4 (seta rosa e lenta para o retângulo “Cerca de 4 linhas”, em estado degradado), e os outros 36 são barrados com ✕, descartados porque o filtro veio depois da busca. Abaixo, três saídas em verde, nas setas pontilhadas: a varredura iterativa continua percorrendo o índice até completar o número pedido (o pgvector oferece hnsw.iterative_scan com os modos strict_order e relaxed_order, limitado por hnsw.max_scan_tuples, padrão 20.000), o índice parcial ou a partição cria um índice por valor do filtro, útil quando há poucos valores, e o filtro dentro do grafo (o Qdrant adiciona arestas ao HNSW conforme valores indexados de metadados, para filtrar durante a navegação, e o índice de metadados deve ser criado antes da ingestão). O Qdrant também troca de método sozinho: se a estimativa de pontos que passam no filtro fica abaixo do full_scan_threshold (10.000 KB por padrão), ele faz uma varredura completa em vez de usar o grafo. Voltaremos aos metadados no Reranking, e a implementação em cada banco é assunto da aula Banco de dados vetorial.
O segundo limite é a estrutura dos dados. Todos os índices apostam que os vetores têm vizinhança, isto é, que existem regiões densas e regiões vazias. Se os vetores forem aleatórios, sem agrupamento, a aposta falha, o efeito de concentração de distâncias visto em Similaridade entre vetores aparece e o índice perde. Medi isso com a hnswlib em 20.000 vetores aleatórios normalizados de 384 números, com consultas próximas de vetores existentes, M = 16 e ef_construction = 200: o recall@10 foi cerca de 0,11 com ef = 10, de 0,44 a 0,45 com ef = 100, de 0,87 a 0,88 com ef = 500 e de 0,997 com ef = 2.000, quase um décimo do acervo examinado. Compare com os 0,996 do HNSW com ef = 64 sobre os vetores com grupos. O HNSW não falhou aqui: os números de qualquer benchmark só valem para dados parecidos com os que o testaram. Embeddings reais têm estrutura, mas cada modelo e cada acervo tem a sua, o que reforça o teste da seção 10 com seus próprios vetores.
O terceiro limite é a vida do índice. Acervos mudam: entram trechos novos, saem trechos velhos. O IVF aceita vetores novos (vão para a lista do centroide mais próximo) mas deixa os centroides envelhecerem. O HNSW aceita inserções construindo arestas na hora, e o custo de construir, a 100.000 vetores, foi de 45 a 54 s na minha medição, contra poucos segundos para o IVF num teste separado com 200.000 vetores (4 s contra 108 s para o HNSW, com o mesmo código, em outro conjunto sintético parecido). Por fim, a memória: o HNSW precisa do grafo e dos vetores em RAM para ser rápido, o que o torna caro em acervos grandes, e é o que leva muita gente a combinar com quantização.
Duas observações finais de projeto. A primeira: com poucos vetores, não use índice. A força bruta é exata, não tem parâmetros e, a 50 mil vetores, custa menos de um milissegundo. Nenhum dos três índices compensa a complexidade abaixo de algumas dezenas ou centenas de milhares de vetores, e o ponto de virada depende do seu orçamento de latência (meça). A segunda: o índice vetorial é um irmão distante da B-Tree. Os dois são estruturas montadas de antemão que aceleram leituras e cobram em espaço e em escrita. A diferença é que a B-Tree é exata e o índice vetorial, quase sempre, aproximado, e por isso pede um parâmetro de recall que um índice de banco comum não tem.
12. Em código
O código abaixo reproduz as medições da seção 9. Rodei o trecho literalmente, em Python 3.12 com numpy 2.5.4 e faiss-cpu 1.15.1, que instalei no ambiente descartável da sessão (nada foi instalado fora dele). Ele gera 100.000 vetores sintéticos com 300 “assuntos”, normaliza (assim, o produto escalar equivale ao cosseno), constrói quatro índices e mede o recall contra a busca exata.
import time
import numpy as np
import faiss
faiss.omp_set_num_threads(1) # uma thread, para comparar justo
rng = np.random.default_rng(42)
N, D, C, NQ, K = 100_000, 384, 300, 100, 10
# dados sintéticos com estrutura: 300 "assuntos" e vetores espalhados em volta
centros = rng.standard_normal((C, D)).astype(np.float32)
def gerar(n):
v = centros[rng.integers(0, C, n)] + 1.6 * rng.standard_normal((n, D)).astype(np.float32)
return v / np.linalg.norm(v, axis=1, keepdims=True) # normaliza: produto escalar = cosseno
X, Q = gerar(N), gerar(NQ)
def medir(indice, nome):
t = time.perf_counter()
I = np.vstack([indice.search(Q[i:i+1], K)[1] for i in range(NQ)])
ms = (time.perf_counter() - t) / NQ * 1000
rec = np.mean([len(set(I[i]) & set(exato[i])) / K for i in range(NQ)])
print(f"{nome:28s} recall@{K} = {rec:.3f} {ms:.3f} ms/consulta")
# 1. busca exata (a verdade a que os outros serão comparados)
flat = faiss.IndexFlatIP(D); flat.add(X)
exato = flat.search(Q, K)[1]
medir(flat, "exata (Flat)")
# 2. HNSW: M=16 vizinhos por nó, efConstruction=200
t = time.perf_counter()
hnsw = faiss.IndexHNSWFlat(D, 16, faiss.METRIC_INNER_PRODUCT)
hnsw.hnsw.efConstruction = 200
hnsw.add(X)
print(f"HNSW construído em {time.perf_counter() - t:.1f} s")
for ef in (8, 16, 32, 64, 128):
hnsw.hnsw.efSearch = ef
medir(hnsw, f"HNSW efSearch={ef}")
# 3. IVF: 1024 listas; nprobe = quantas listas visitar
ivf = faiss.IndexIVFFlat(faiss.IndexFlatIP(D), D, 1024, faiss.METRIC_INNER_PRODUCT)
ivf.train(X); ivf.add(X)
for nprobe in (1, 2, 4, 16):
ivf.nprobe = nprobe
medir(ivf, f"IVF nprobe={nprobe}")
# 4. IVF + PQ: cada vetor vira 48 bytes (em vez de 384 x 4 = 1536)
pq = faiss.IndexIVFPQ(faiss.IndexFlatIP(D), D, 1024, 48, 8, faiss.METRIC_INNER_PRODUCT)
pq.train(X); pq.add(X); pq.nprobe = 16
medir(pq, "IVF+PQ (48 bytes) nprobe=16")
for nome, ind in (("Flat", flat), ("HNSW", hnsw), ("IVF", ivf), ("IVF+PQ", pq)):
print(f"{nome:7s} {len(faiss.serialize_index(ind)) / 1e6:7.1f} MB")
A saída da segunda execução (a primeira deu os mesmos recalls e tempos até 20% menores, como 3,2 ms para o Flat) foi a seguinte. A execução inteira levou 74 s, quase todos na construção do HNSW.
exata (Flat) recall@10 = 1.000 3.884 ms/consulta
HNSW construído em 53.7 s
HNSW efSearch=8 recall@10 = 0.626 0.049 ms/consulta
HNSW efSearch=16 recall@10 = 0.828 0.067 ms/consulta
HNSW efSearch=32 recall@10 = 0.958 0.096 ms/consulta
HNSW efSearch=64 recall@10 = 0.996 0.152 ms/consulta
HNSW efSearch=128 recall@10 = 1.000 0.237 ms/consulta
IVF nprobe=1 recall@10 = 0.544 0.045 ms/consulta
IVF nprobe=2 recall@10 = 0.818 0.051 ms/consulta
IVF nprobe=4 recall@10 = 0.979 0.058 ms/consulta
IVF nprobe=16 recall@10 = 1.000 0.115 ms/consulta
IVF+PQ (48 bytes) nprobe=16 recall@10 = 0.366 0.110 ms/consulta
Flat 153.6 MB
HNSW 168.0 MB
IVF 156.0 MB
IVF+PQ 7.6 MB
Três detalhes do código merecem explicação. O faiss.omp_set_num_threads(1) e o laço de uma consulta por vez deixam a comparação justa e simulam o caso do servidor que atende uma pergunta de cada vez. Os parâmetros do HNSW (M = 16, efConstruction = 200) são os que escolhi, e os padrões do FAISS podem ser diferentes. E o recall é a conta da seção 4, feita na mão: interseção dos conjuntos de ids dividida por k, com média sobre as 100 consultas.
O refino da figura 10 usa uma classe do FAISS que guarda os vetores originais junto do índice comprimido. Rodei o trecho a seguir sobre os mesmos dados, depois do código acima (a variável medir e os dados X e Q são os mesmos).
for m in (48, 96):
pq = faiss.IndexIVFPQ(faiss.IndexFlatIP(D), D, 1024, m, 8, faiss.METRIC_INNER_PRODUCT)
pq.train(X)
ref = faiss.IndexRefineFlat(pq) # guarda também os vetores originais
ref.add(X) # adiciona ao PQ e ao armazenamento exato
pq.nprobe = 16
medir(pq, f"IVF+PQ m={m} ({m} bytes) sem refino")
ref.k_factor = 10 # pede 10*K candidatos ao PQ e reordena com a conta exata
medir(ref, f"IVF+PQ m={m} + refino exato (x10)")
A saída: com 48 bytes, o recall foi de 0,366 para 0,955 (0,115 para 0,151 ms por consulta), e com 96 bytes foi de 0,643 para 1,000 (0,149 para 0,177 ms). O refino custou menos de 0,05 ms e recuperou quase todo o recall perdido. Para o HNSW em Python puro, sem FAISS, a biblioteca hnswlib tem uma API parecida com a que as documentações descrevem, e o teste de dados aleatórios da seção 11 usou o trecho abaixo.
import numpy as np
import hnswlib
rng = np.random.default_rng(1)
N, D = 20_000, 384
X = rng.standard_normal((N, D)).astype(np.float32)
X /= np.linalg.norm(X, axis=1, keepdims=True)
q = X[:100] + 0.1 * rng.standard_normal((100, D)).astype(np.float32) # consultas perto de vetores existentes
q /= np.linalg.norm(q, axis=1, keepdims=True)
indice = hnswlib.Index(space="ip", dim=D) # "ip" = produto interno; também "cosine" e "l2"
indice.init_index(max_elements=N, ef_construction=200, M=16)
indice.add_items(X, np.arange(N))
exato = np.argsort(-(q @ X.T), axis=1)[:, :10]
for ef in (10, 100, 500, 2000):
indice.set_ef(ef)
ids, _ = indice.knn_query(q, k=10)
rec = np.mean([len(set(ids[i]) & set(exato[i])) / 10 for i in range(len(q))])
print(f"ef={ef:3d} recall@10 = {rec:.3f}")
A saída que obtive, com a hnswlib 0.8.0 (em outra execução os números variaram na segunda casa decimal, porque a construção usa várias threads): recall@10 de 0,111 com ef = 10, 0,446 com 100, 0,881 com 500 e 0,998 com 2.000. A construção levou 7 s. Em produção, você raramente chama FAISS ou hnswlib direto: usa o motor do seu banco vetorial, que expõe os mesmos botões, como a tabela da seção 10 mostra.
Parte 4: Fechamento e prática
13. Erros comuns
O primeiro erro é medir só a latência. Um índice com ef = 8 responde em 0,05 ms e parece ótimo até alguém notar que 37% dos vizinhos verdadeiros sumiram (0,626 de recall na seção 9). A correção é medir recall e latência juntos, sempre, e comparar os dois contra a busca exata.
O segundo é escolher parâmetros copiando um tutorial. Os padrões (ef_search = 40, probes = 1) existem para o índice funcionar, e não para o seu acervo. O pgvector avisa que, com probes = 1, o IVFFlat visita uma lista só, e a seção 6 mostrou que uma lista visitada rendeu recall de 0,544. A correção é o roteiro da seção 10, com consultas parecidas com as reais.
O terceiro é criar um índice IVF sobre uma tabela vazia ou quase vazia. Os centroides saem de poucos dados, as listas ficam desequilibradas e o recall despenca. O pgvector avisa para criar o IVFFlat depois de ter dados. O HNSW não tem esse problema, por não ter treino. A correção é ingerir primeiro, indexar depois, e retreinar quando o acervo mudar muito.
O quarto é esperar que o filtro seja grátis. A seção 11 mostrou o filtro aplicado depois do índice devolvendo 4 linhas em vez de 10. A correção é conhecer como o seu motor trata filtros (iteração, partição, filtro no grafo) e testar com os filtros reais, de preferência os muito seletivos.
O quinto é usar a métrica do índice diferente da do modelo. O índice é construído para uma métrica (vector_cosine_ops, METRIC_INNER_PRODUCT, space="ip") e a consulta precisa usar a mesma, senão o índice não é usado ou devolve ordem errada, e nenhum erro avisa. O cuidado já foi listado em Similaridade entre vetores; aqui ele reaparece porque o índice fixa a métrica na construção.
O sexto é comprimir sem refinar, ou refinar sem guardar os originais. A PQ de 48 bytes mediu 0,366 de recall sem refino, e o refino só funciona se os vetores originais continuarem acessíveis. A correção é decidir os dois juntos: se a memória é o gargalo, mantenha os originais em disco e confira a latência do refino.
O sétimo é tratar o recall do índice como a qualidade do RAG. Um recall de 0,98 diz que o índice reproduz a busca exata em 98%, e a busca exata em si pode estar errada (modelo de embedding inadequado, trechos mal cortados). A correção é medir as duas coisas: o índice contra a busca exata, e o sistema contra perguntas reais.
14. Exercícios
Os exercícios 1 a 3 são contas à mão. Os demais rodam em CPU, num ambiente com numpy e, a partir do 4, com faiss-cpu (e hnswlib para o 6), sem chave de API.
-
Com k = 10, a busca exata devolve os ids 3, 8, 12, 15, 21, 30, 44, 51, 60 e 72, e o índice devolve 3, 8, 12, 15, 21, 30, 44, 99, 100 e 101. Calcule o recall@10. Se o índice tivesse devolvido os mesmos ids em outra ordem, o recall mudaria? (Resposta: 7 ÷ 10 = 0,7; não mudaria, porque o recall conta quais vizinhos vieram e não a posição.)
-
Um acervo tem 4 milhões de vetores e você usa IVF com
nlist= 2.000. Estime quantas comparações a consulta faz comnprobe= 10 (2.000 centroides mais as listas), supondo listas de tamanho igual, e compare com a busca exata. (Resposta: 2.000 centroides mais 10 × 2.000 vetores por lista = 22.000 contas, perto de 180 vezes menos que 4 milhões. Se as listas forem desiguais, o número varia, e as maiores pesam mais.) -
Calcule a memória de 10 milhões de vetores de 768 números em
float32e depois com PQ de 96 bytes por vetor. Some 8 bytes de id por vetor no segundo caso. (Resposta: 10.000.000 × 768 × 4 = 30,72 GB; com PQ, 10.000.000 × (96 + 8) = 1,04 GB, sem contar os centroides, o dicionário e os originais usados no refino.) -
Rode o código da seção 12 no seu computador e compare. O recall deve coincidir com a tabela da seção 9 (a semente é fixa), e os tempos devem mudar. Depois troque a thread única por todas as threads (
faiss.omp_set_num_threadscom o número de núcleos) e veja o que acontece com a busca exata e com o HNSW, uma consulta por vez. -
Varie o
Mdo HNSW (8, 16, 32, 64) comefSearch= 32 fixo e anote recall, tempo e tamanho do índice (faiss.serialize_index). Compare a ordem de grandeza do aumento de memória com a estimativaM × 8 a 10 bytespor vetor da hnswlib. -
Repita o teste de dados aleatórios da seção 11 com 100.000 vetores, e depois com 20.000 embeddings reais de frases suas (pode gerar variações ou usar um conjunto público). Compare as curvas de recall por
ef. Explique em um parágrafo por que as duas diferem, usando o conceito de concentração de distâncias. -
Monte o roteiro da figura 13 com os seus dados: escolha 100 consultas, calcule a verdade com
IndexFlatIP, construa um HNSW e um IVF, e encontre o menorefe o menornprobeque dão recall@10 maior ou igual a 0,95. Anote o p95 de latência de cada um (usenp.percentile). -
Combine IVF, PQ e refino como na figura 10 com o seu acervo (ou com o dado sintético) e varie o
k_factor(1, 2, 5, 10, 20). Trace o recall contra o tempo e diga em que ponto ganhar mais recall deixa de valer o tempo gasto.
15. Glossário
| Termo | Significado |
|---|---|
| Busca vetorial | Achar, dado um vetor de consulta, os vetores do acervo mais parecidos com ele |
| k-NN | Os k vizinhos mais próximos, em geral calculados de forma exata |
| ANN | Vizinhos mais próximos aproximados: aceitar um resultado parcialmente exato em troca de velocidade |
| Força bruta (busca exata) | Comparar a consulta com todos os vetores; sempre certa, custo linear |
| Índice | Estrutura montada de antemão para tornar as consultas mais baratas |
| Recall@k | Fração dos k vizinhos verdadeiros que o índice também devolveu |
| IVF | Índice de arquivo invertido: agrupa os vetores em listas por centroide e visita só algumas |
| k-means | Algoritmo de agrupamento que acha centros (centroides) movendo-os para a média dos vetores atribuídos |
| Centroide | O ponto central de um grupo de vetores |
nlist e nprobe | Número de listas do IVF e número de listas visitadas por consulta |
| HNSW | Grafo em camadas de vizinhos, navegado de cima para baixo e de forma gulosa |
M, ef_construction, ef | Vizinhos por nó, esforço de construção e esforço de consulta no HNSW |
| Quantização | Trocar números por códigos mais curtos, ao custo de precisão |
| PQ | Quantização por produto: divide o vetor em pedaços e guarda o número do centroide de cada pedaço |
| Refino (rescore) | Reordenar os candidatos com a conta exata, usando os vetores originais |
| p95 | Tempo abaixo do qual ficam 95% das consultas |
| Filtro após o índice | Filtro de metadados aplicado depois que o índice devolve os candidatos, o que pode deixar menos resultados que o pedido |
Fontes
Consultadas em 15 de outubro de 2026.
- pgvector (README):
m= 16,ef_construction= 64 ehnsw.ef_search= 40 por padrão; HNSW com melhor relação velocidade e recall que o IVFFlat, porém mais lento para construir e mais pesado em memória; HNSW criável em tabela vazia; IVFFlat criado depois de haver dados,lists(rows / 1000até 1 milhão de linhas,sqrt(rows)acima),probes= 1 por padrão, começar comsqrt(lists)eprobesigual alistsdá busca exata; filtro aplicado depois da varredura aproximada (exemplo de 10% das linhas eef_search= 40 devolvendo cerca de 4 linhas); varredura iterativa (strict_order,relaxed_order,hnsw.max_scan_tuples= 20.000); índices parciais e partições;halfvecebinary_quantizecom reordenação dos candidatos. - Qdrant: indexação: HNSW como estrutura em várias camadas,
m= 16 eef_construct= 100 por padrão,efde busca; arestas extras por valor de metadado indexado (índice de metadados criado antes da ingestão);full_scan_thresholdde 10.000 KB. Li só os primeiros 100.000 dos cerca de 113.200 caracteres da página. - Qdrant: quantização: escalar (cerca de 4 vezes, erro geralmente menor que 1%), binária (até 32 vezes, recomendada com rescore e vetores de muitas dimensões), produto (até 64 vezes, quando a memória é a prioridade e velocidade e precisão não são críticas), rescore e oversampling (exemplo de 2,4 com limite 100 pré-selecionando 240 candidatos). Li só os primeiros 100.000 dos cerca de 103.000 caracteres da página.
- hnswlib: parâmetros do algoritmo:
Mentre 2 e 100 e 12 a 48 para a maioria dos casos, memória em torno deM × 8 a 10 bytespor elemento,efnão pode ser menor que k, dica de verificação doef_construction(recall abaixo de 0,9 indica espaço para melhorar). - FAISS wiki: Faiss indexes: bytes por vetor (
4*dno Flat,4*d + 8no IVFFlat,ceil(M * nbits/8) + 8no IVFPQ),nlistperto deC × sqrt(n),nprobeajustável na consulta e a descrição da falha em que a célula do vizinho mais próximo não é selecionada. A página não trata de como medir recall. - FAISS wiki: Getting started:
IndexFlatL2como busca exata sem treino. - Malkov e Yashunin, “Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs” (arXiv 1603.09320): camadas de grafos de proximidade com sorteio da camada máxima de cada elemento e a afirmação de escala logarítmica.
- Jégou, Douze e Schmid, “Product quantization for nearest neighbor search”, IEEE TPAMI 33(1), 2011, pp. 117 a 128 (DOI 10.1109/TPAMI.2010.57), lido por resumo no registro do HAL via busca (não consegui abrir o PDF): decomposição do espaço em subespaços quantizados separadamente, estimativa de distância pelo código, variante assimétrica, combinação com arquivo invertido e teste em dois bilhões de vetores.
- Similaridade entre vetores, Embeddings e Reranking: custo da busca exata, concentração de distâncias e a divisão de papéis.
Medido por mim (ambiente descartável no scratchpad da sessão, Python 3.12.8, numpy 2.5.4, faiss-cpu 1.15.1 e hnswlib 0.8.0, instalados só nesse ambiente; MacBook com M1 Max, uma thread, consultas uma de cada vez): toda a tabela e as saídas da seção 12, o refino com IndexRefineFlat, o teste da hnswlib com dados aleatórios, os tamanhos serializados dos índices e as comparações de tempo de construção. O código mostrado é o que executei. Os recalls repetiram nas duas execuções do código principal, e os tempos variaram em torno de 10 a 20%.
Sem execução ou sem fonte: todos os vetores são sintéticos (300 grupos com ruído gaussiano, normalizados), e não medi nenhum índice sobre embeddings reais de texto, nem com mais de 200.000 vetores, nem com várias threads ou vários clientes; o recall baixo da PQ de 48 bytes (0,366) vale para estes dados ruidosos e pode ser melhor em embeddings reais. A comparação de tempo de construção (4 s do IVF contra 108 s do HNSW com 200.000 vetores) vem de um teste separado em outro conjunto sintético parecido e não está no código mostrado. As frases sobre retreinar o IVF quando o acervo muda e sobre a dificuldade de remover itens do HNSW são conhecimento geral da área, sem fonte consultada hoje. O ponto de virada abaixo do qual a força bruta basta (“algumas dezenas ou centenas de milhares”) é uma estimativa minha. Não consultei os motores Elasticsearch, Milvus, Weaviate e Pinecone, e os valores padrão citados são só os do pgvector, do Qdrant e da hnswlib. Os valores de 20 milhões e 1 bilhão de vetores nas seções 2 e 8 e os exercícios 2 e 3 são contas ilustrativas. Os 14 diagramas foram renderizados e revisados.