Do Cosseno à Borda: Busca Semântica em Produção

por Frank de Alcantara em 19/07/2026

Este artigo também está disponível em inglês.

Do Cosseno à Borda: Busca Semântica em Produção

1. Uma Confissão e um Projeto

Durante treze artigos, esta série construiu maquinário matemático: vetores que representam palavras, produtos de matrizes que os transformam, mecanismos de atenção que os contextualizam. Tudo com a promessa implícita de que essa matemática serve para alguma coisa. Este artigo paga a promessa. Vamos construir, do produto interno ao deploy, a busca semântica que está funcionando neste exato momento no menu deste blog e que a atenta leitora pode interrogar em /busca/ antes, durante e depois da leitura. A aplicação completa deste artigo, pela primeira vez na série, está em produção. Os laboratórios aparecem junto da matemática que permitem manipular: o cosseno na Seção 4, a InfoNCE na Seção 7 e a busca aproximada na Seção 9.

Antes, a confissão. Até pouco tempo atrás, a busca deste blog era feita por uma biblioteca JavaScript chamada lunr.js, executada inteiramente no navegador. Como um site estático não tem servidor para processar consultas, a busca client-side precisa levar os documentos indexáveis até o cliente para montar o índice. O mecanismo herdado serializava integralmente as páginas e os artigos marcados com access: open; dos demais artigos, levava as primeiras 80 palavras. No build atual dessa implementação antiga, o arquivo gerado tem cerca de 70 kB, aos quais se somam aproximadamente 85 kB da biblioteca Lunr, ambos medidos antes da compressão HTTP. Não eram megabytes nem o conteúdo integral de todos os artigos, portanto. O defeito de engenharia era outro: cada visitante pagava a transferência e a construção de um índice cujo tamanho crescia com o acervo, mesmo que não fizesse consulta alguma.

O pobre autor descobriu isso auditando o próprio site, na circunstância clássica em que se descobrem essas coisas: procurando outro problema. Há que se perdoar o inocente. Ele herdou o template de outro projeto e nunca havia se preocupado com a estrutura ou com a tecnologia adotada.

Havia, portanto, dois defeitos para corrigir, um de engenharia e um de qualidade. O de engenharia acabamos de descrever.

O de qualidade é mais sutil e mais interessante: a busca léxica, mesmo bem servida, não entende o que procura. É esse defeito que ocupará a maior parte deste artigo, porque corrigi-lo exige exatamente a matemática dos artigos anteriores: embeddings, atenção, softmax e produtos de matrizes, acrescidos de um ingrediente novo que deduziremos com calma: uma noção formal de proximidade entre significados. No caminho, manteremos o compromisso da série: nenhum objeto será usado antes de definido, nenhuma afirmação ficará sem mecanismo, número ou exemplo, e a atenta leitora verá as contas feitas à mão antes de vê-las executadas por uma GPU da infraestrutura da Cloudflare.

A palavra pobre é importante neste contexto. Como tudo, tudo, tudo neste blog, por enquanto, é gratuito, e mantido em sistemas gratuitos. A solução tem que ser gratuita. Então, estão fora de cogitação os maravilhosos algoritmos que os assistentes de inteligência artificial usam.

O plano do artigo espelha o trajeto de uma consulta. As Seções 2 e 3 estabelecem o problema: por que a busca por palavras falha e o que significa buscar por significado. As Seções 4 a 7 constroem a matemática: a similaridade de cosseno, o encoder que transforma texto em vetor e o treinamento contrastivo que dá sentido geométrico a esses vetores. As Seções 8 a 10 constroem o sistema: o fatiamento dos textos, o índice vetorial e a arquitetura na borda da Cloudflare, com as cicatrizes de produção documentadas onde dói. A Seção 11 fecha as contas: quanto custa (spoiler: o custo fixo é zero) e onde estão os limites.

2. O Que a Busca Léxica Vê

Para criticar a busca por palavras com justiça, precisamos primeiro descrevê-la com precisão. O modelo clássico de recuperação de informação representa cada documento como um saco de palavras, ou bag of words. Este modelo mede a relevância de um documento $d$ para uma consulta $q$ somando, sobre os termos $t$ da consulta, um peso que cresce com a frequência do termo no documento e decresce com a popularidade do termo na coleção. A versão mais conhecida desse peso é o TF-IDF, de term frequency–inverse document frequency (frequência do termo–frequência inversa nos documentos):

\[w_{t,d} \;=\; \mathrm{tf}_{t,d} \cdot \log\frac{N}{\mathrm{df}_t}\]

na qual $\mathrm{tf}_{t,d}$ é o número de ocorrências do termo $t$ no documento $d$, $N$ é o número de documentos da coleção e $\mathrm{df}_t$ é o número de documentos que contêm $t$. O logaritmo comprime a escala: um termo presente em todos os $N$ documentos recebe peso $\log(N/N) = 0$. Palavras onipresentes como “de” e “que” não distinguem nada, enquanto um termo presente em um único documento recebe o peso máximo $\log N$. Refinamentos como o BM25, que satura a contribuição de $\mathrm{tf}$ e normaliza pelo comprimento do documento, dominam a busca léxica há três décadas, e o lunr.js implementa um parente próximo dessa família.

O ponto fraco não está no peso; está no dicionário. Para o modelo bag of words, cada termo é um símbolo opaco, e dois símbolos diferentes são simplesmente diferentes. A consulta “computação” não encontra um documento que só diga “computacional”, a menos que um stemmer, o componente que reduz palavras ao radical, saiba que ambas compartilham o radical “comput”. O lunr.js usa por padrão o stemmer de Porter, projetado para o inglês; aplicado ao português, ele trata “computação” e “computacional” como estranhos que nunca se viram. Mesmo um stemmer perfeito para o português não salvaria a consulta “como funciona atenção em redes neurais” diante de um artigo que fale de “mecanismos de attention em Transformers”: não há radical compartilhado entre “atenção” e “attention”, apenas significado compartilhado. A busca léxica opera sobre a grafia; o que queremos é operar sobre a semântica. Significado é tudo. Semântica é significado.

A persistente leitora que acompanhou a série reconhece o diagnóstico: é o mesmo problema da representação one-hot do artigo de vetorização básica, no qual cada palavra virou um vetor com um único 1 e o produto interno entre palavras diferentes era sempre zero. A solução, lá como aqui, é trocar símbolos opacos por vetores densos que codifiquem uso e contexto. A diferença é de escala: lá vetorizávamos palavras; agora precisamos vetorizar parágrafos inteiros, e medir distâncias entre eles.

3. Do Significado à Geometria

A hipótese que sustenta tudo o que segue foi enunciada na série desde o artigo sobre embeddings distribuídos: palavras que ocorrem em contextos parecidos têm significados parecidos. Essa é a hipótese distribucional de Harris e Firth. O word2vec, dissecado em três artigos da série, materializa a hipótese: treina vetores para atribuir produtos internos maiores aos pares observados juntos com frequência e menores aos pares negativos amostrados. O resultado é um espaço no qual “rei” e “rainha” ficam próximos e no qual certas direções codificam relações.

Eu sei! Ninguém aguenta mais essa metáfora. Mas pobre de mim: é domingo, a Espanha acabou de ganhar a Copa do Mundo de 2026 e eu estou cansado.

Seria tentador vetorizar um parágrafo somando ou tirando a média dos vetores de suas palavras. A tentação é instrutiva porque falha por motivos que a série já explicou. Primeiro, a média destrói a ordem: “o professor reprovou o aluno” e “o aluno reprovou o professor” têm exatamente a mesma média de vetores e significados opostos. Esse é o problema de modelagem de sequências do artigo sobre sequências. Segundo, o word2vec atribui um único vetor a cada grafia: “banco” de sentar e “banco” de dinheiro dividem o mesmo ponto no espaço, uma polissemia esmagada que contamina qualquer média. O remédio para ambos os males é conhecido da leitora desde o artigo sobre atenção: calcular o vetor de cada palavra olhando para as outras palavras da sentença. Precisamos de um encoder contextual, também conhecido nos bares da vida como Transformer, que leia o parágrafo inteiro e produza um único vetor que o represente.

Fixemos, então, o contrato que o resto do artigo cumprirá. Queremos uma função

\[E : \text{texto} \;\longrightarrow\; \mathbb{R}^{1024}\]

que leve um trecho de texto, uma consulta de sete palavras ou um parágrafo de trezentas, a um vetor de 1024 números reais, com a propriedade de que textos de significado próximo sejam levados a vetores próximos. As Seções 5 a 7 construirão $E$; a Seção 4, mais humilde e mais fundamental, define o que “próximo” quer dizer. A escolha do número 1024 não é deste escriba pretensioso: é a dimensão do modelo que usaremos, e a Seção 5 mostrará sua origem.

4. A Similaridade de Cosseno

Já discutimos a similaridade de cosseno em um ou outro artigo desta série. Mas não vamos nos furtar de revê-la.

Sejam $u, v \in \mathbb{R}^n$ dois vetores. O produto interno entre eles é a soma dos produtos componente a componente, e a norma de um vetor é a raiz do produto interno consigo mesmo:

\[\langle u, v \rangle \;=\; \sum_{i=0}^{n-1} u_i\, v_i, \qquad \lVert u \rVert \;=\; \sqrt{\langle u, u \rangle}.\]

A similaridade de cosseno entre $u$ e $v$, ambos não nulos, é o produto interno normalizado pelas normas:

\[\cos(u, v) \;=\; \frac{\langle u, v \rangle}{\lVert u \rVert \, \lVert v \rVert}.\]

O nome não é decorativo: em $\mathbb{R}^2$ e $\mathbb{R}^3$, essa quantidade é exatamente o cosseno do ângulo entre os dois vetores, e em dimensão qualquer ela é definida como tal. A garantia de que a definição faz sentido, de que o valor cai sempre em $[-1, 1]$, como um cosseno decente, é a desigualdade de Cauchy–Schwarz, $\lvert \langle u, v \rangle \rvert \le \lVert u \rVert \lVert v \rVert$, que vale em qualquer espaço com produto interno.

Façamos as contas à mão uma vez, em $\mathbb{R}^3$, com números que reaparecerão no laboratório ao fim desta seção. Sejam $u = (2, 1, 0)$ e $v = (1, 2, 0)$:

\[\langle u, v \rangle = 2\cdot1 + 1\cdot2 + 0\cdot0 = 4, \qquad \lVert u \rVert = \sqrt{4+1+0} = \sqrt{5}, \qquad \lVert v \rVert = \sqrt{1+4+0} = \sqrt{5},\] \[\cos(u, v) = \frac{4}{\sqrt{5}\cdot\sqrt{5}} = \frac{4}{5} = 0{,}8.\]

Dois vetores apontando em direções vizinhas: similaridade alta. Agora $w = (-1, 2, 0)$ contra o mesmo $u$: o produto interno é $2\cdot(-1) + 1\cdot2 + 0 = 0$, e a similaridade é $0$, portanto os vetores são ortogonais. No espaço aprendido, isso indica ausência de alinhamento direcional, não prova que os textos sejam semanticamente independentes. E se dobrarmos $v$ para $v’ = (2, 4, 0)$? O produto interno dobra para $8$, mas a norma de $v’$ também dobra para $2\sqrt{5}$, e a similaridade continua $8/(\sqrt{5}\cdot 2\sqrt{5}) = 8/10 = 0{,}8$. Esta é a propriedade que justifica a escolha do cosseno para comparar vetores: ele é invariante à multiplicação de um vetor por um escalar positivo. A propriedade remove a magnitude do vetor da comparação; não afirma, por si só, que um parágrafo longo e uma frase curta sobre o mesmo assunto receberão a mesma representação.

A leitora pode objetar: por que não a distância euclidiana $\lVert u - v \rVert$, mais familiar? Para vetores de norma um, a objeção evapora, e vale a pena ver por quê. Expandindo o quadrado da distância pela bilinearidade do produto interno:

\[\lVert u - v \rVert^2 \;=\; \langle u-v,\, u-v \rangle \;=\; \lVert u \rVert^2 + \lVert v \rVert^2 - 2\langle u, v \rangle \;=\; 2 - 2\cos(u,v),\]

na qual a última igualdade usa $\lVert u \rVert = \lVert v \rVert = 1$. Distância euclidiana e similaridade de cosseno são, sobre a esfera unitária, funções monótonas uma da outra: ordenar documentos por menor distância ou por maior cosseno produz exatamente a mesma classificação. Por isso o modo denso do BGE-M3 normaliza os vetores antes da comparação. Nosso índice Vectorize foi configurado explicitamente com a métrica de cosseno; matematicamente, seus resultados de alta precisão poderiam ser ordenados também por produto interno ou distância euclidiana entre esses vetores normalizados. O produto interno é a operação que a série passou um artigo inteiro aprendendo a fazer depressa. Pressa é importante no mundo moderno da tecnologia.

4.1 Laboratório: Meça Ângulos

Agora que a conta e a equivalência estão à vista, podemos torná-las manipuláveis. O laboratório abaixo coloca os vetores $u$ e $v$ no plano e recalcula, a cada arrasto, o produto interno, as normas, o cosseno e a distância euclidiana depois da normalização. Comece por $u=(2,1)$ e $v=(1,2)$, o exemplo que produziu $0{,}8$; dobre o comprimento de $v$ sem mudar sua direção e confirme que o cosseno não se move; depois procure a posição ortogonal em que a similaridade zera. Os botões recompõem esses três casos a um clique, mas vale arrastar as pontas e obrigar a geometria a confessar com as próprias mãos.

5. O Encoder: a Atenção, Agora Completa

A função $E$ da Seção 3 será o BGE-M3, um modelo aberto da BAAI (Beijing Academy of Artificial Intelligence) publicado por Chen e colegas em 2024. Antes dos detalhes, a genealogia, porque ela explica a arquitetura: o BGE-M3 herda a arquitetura e o tokenizador do XLM-RoBERTa-large, um Transformer encoder multilíngue de 24 camadas. Seu treinamento posterior incorporou dados de 194 línguas, com português entre elas. As dimensões que usaremos ao longo do artigo vêm da configuração publicada do modelo: dimensão $d = 1024$, $h = 16$ cabeças de atenção e um vocabulário com 250.002 subpalavras.

E, como eu disse antes, ser pobre significa que tenho que escolher coisas eficientes, porém baratas. De preferência gratuitas.

O caminho de um texto pelo encoder tem quatro estágios: tokenização, embedding, as 24 camadas de atenção e, na Seção 6, o pooling que colapsa tudo num vetor só. A esforçada leitora deve se preparar, pois vamos percorrer cada um desses estágios. Vai demorar. Pode parar, tomar uma água e rezar um pai-nosso, sem pressa. Eu espero.

5.1 Tokenização e a camada de embedding

O texto de entrada é quebrado em subpalavras pelo tokenizador SentencePiece herdado do XLM-RoBERTa: unidades que podem ser palavras inteiras e frequentes (“de”, “atenção”) ou fragmentos (“comput”, “acional”), a mesma ideia de subpalavras discutida no artigo sobre a probabilidade da linguagem. O vocabulário compartilhado coloca todas as línguas no mesmo conjunto de identificadores e permite reaproveitar fragmentos quando há sobreposição ortográfica. Isso não basta para tornar “computação” e “computation” semanticamente próximas, nem exige que as duas palavras compartilhem fragmentos. A equivalência entre línguas é aprendida pelo encoder com contextos multilíngues e pares paralelos, como veremos na Seção 7.

Em algum artigo da série eu chamei as subpalavras de token. Mas hoje o lexema token está ganhando um sentido novo. Quase econômico. Então, vamos de subpalavra mesmo.

Cada subpalavra é trocada por uma linha de uma matriz de embedding $W_E \in \mathbb{R}^{\vert V\vert \times d}$, na qual $\vert V\vert = 250\,002$ e $d = 1024$.

Aqui cabe uma pausa em prol da sanidade aritmética, no espírito da série: só essa tabela tem $250\,002 \times 1024 = 256\,002\,048$ parâmetros. Cerca de 45% dos aproximadamente 568 milhões do modelo inteiro. Ou seja, quase metade do BGE-M3 é dicionário; a outra metade, que calcula, são as 24 camadas que veremos a seguir. A cada vetor de subpalavra soma-se ainda um vetor de posição, para que a camada seguinte saiba a ordem. Sem ele, voltaríamos ao bag of words da Seção 2. Finalmente, o tokenizador coloca o marcador especial <s> no início da sequência. O artigo do BGE-M3 o chama de [CLS], notação que adotaremos daqui em diante; ele não representa palavra nenhuma, e a Seção 6 revelará seu ofício.

O resultado do estágio é uma matriz $X \in \mathbb{R}^{L \times d}$: uma linha por token, $L$ tokens no total. O checkpoint original do BGE-M3 documenta sequências de até 8.192 tokens, extensão construída durante a etapa RetroMAE; seu arquivo de configuração registra 8.194 posições máximas. O endpoint da Cloudflare usado neste blog anuncia atualmente uma janela de 60.000 tokens, um limite do serviço hospedado. Nossos trechos de aproximadamente 300 palavras ficam muito abaixo de ambos.

5.2 Uma camada de atenção, símbolo por símbolo

O artigo sobre atenção construiu o mecanismo com calma; aqui vamos deduzi-lo completo, de uma vez, porque prometemos profundidade e porque a esclarecida leitora já tem os instrumentos matemáticos necessários. Assim, posso ser direto.

Cada uma das 24 camadas executa duas operações: atenção multi-cabeça e uma rede feed-forward, ambas envoltas em conexões residuais e normalização.

A atenção começa projetando $X$ em três papéis. Com matrizes de pesos $W_Q, W_K, W_V \in \mathbb{R}^{d \times d_k}$ aprendidas no treinamento, calculam-se

\[Q = X W_Q, \qquad K = X W_K, \qquad V = X W_V,\]

nas quais $Q$ são as queries (o que cada token pergunta), $K$ as keys (o que cada token oferece como identificador) e $V$ os values (o que cada token entrega se for escolhido).

Com $h = 16$ cabeças, cada cabeça trabalha em dimensão $d_k = d / h = 1024 / 16 = 64$.

A pontuação de compatibilidade entre todos os pares de tokens de uma cabeça é uma multiplicação de matrizes, $Q K^\top \in \mathbb{R}^{L \times L}$: a entrada $(i, j)$ é o produto interno entre a pergunta do token $i$ e a oferta do token $j$, a mesma operação da Seção 4, medindo agora afinidade entre tokens em vez de afinidade entre textos. A GEMM que o artigo 13 ensinou a acelerar é o motor disto tudo, repetido nas 16 cabeças de cada uma das 24 camadas.

As pontuações são divididas por $\sqrt{d_k} = 8$ antes do softmax, e a série deve à leitora a dedução dessa constante. Suponha que as componentes de $q, k \in \mathbb{R}^{d_k}$ sejam variáveis aleatórias independentes de média $0$ e variância $1$, aproximadamente o estado das ativações projetadas no início do treinamento. O produto interno $\langle q, k \rangle = \sum_{i=0}^{d_k-1} q_i k_i$ soma $d_k$ termos independentes, cada um de média $0$ e variância $1$; a variância da soma é a soma das variâncias, $d_k$, e o desvio padrão é $\sqrt{d_k}$. Sem a divisão, as pontuações de uma cabeça com $d_k = 64$ teriam desvio padrão $8$, e o softmax, cuja derivada, como vimos no artigo da temida matemática, se torna muito pequena nos extremos, tenderia a saturar: uma distribuição quase one-hot por acidente estatístico e pouco gradiente atravessando as probabilidades de atenção. A divisão devolve as pontuações à variância $1$, mantendo o softmax numa região mais favorável ao aprendizado.

Com as pontuações escaladas, precisamos ainda impedir que posições preenchidas apenas para igualar comprimentos participem da atenção. Para isso, somamos uma máscara $M \in \mathbb{R}^{L \times L}$, com $M_{ij}=0$ quando a chave $j$ é válida e $M_{ij}=-\infty$ quando ela é preenchimento. Cada linha passa então pelo softmax, que transforma um vetor de pontuações $s \in \mathbb{R}^L$ numa distribuição de probabilidade,

\[\operatorname{softmax}(s)_j = e^{s_j} / \sum_{r=0}^{L-1} e^{s_r}\]

As posições válidas recebem pesos positivos, as mascaradas recebem zero no limite e todos os pesos somam um. O resultado pondera os values:

\[\operatorname{Att}(Q, K, V) \;=\; \operatorname{softmax}\!\left(\frac{Q K^\top}{\sqrt{d_k}} + M\right) V \;\in\; \mathbb{R}^{L \times d_k}.\]

Números pequenos, como sempre, antes das abstrações grandes. Tome uma sequência de $L = 3$ tokens válidos, portanto com $M=0$, numa cabeça de brinquedo com $d_k = 2$, e suponha que as projeções tenham produzido

\[Q = K = V = \begin{pmatrix} 1 & 0 \\ 0 & 1 \\ 1 & 1 \end{pmatrix},\]

na qual a linha $i$ é o vetor do token $i$. Então

\[Q K^\top = \begin{pmatrix} 1 & 0 & 1 \\ 0 & 1 & 1 \\ 1 & 1 & 2 \end{pmatrix},\]

e a leitora deve conferir uma entrada à mão: a posição $(2, 2)$ é $\langle (1,1), (1,1) \rangle = 2$. O token 2, que aponta na diagonal, é o mais parecido consigo mesmo. Dividindo por $\sqrt{d_k} = \sqrt{2} \approx 1{,}414$ e aplicando o softmax à linha 0, as pontuações $(0{,}707;\; 0;\; 0{,}707)$ viram os pesos $(0{,}401;\; 0{,}198;\; 0{,}401)$: o token 0 atende com força igual a si mesmo e ao token 2, que compartilha sua primeira componente, e metade disso ao token 1, ortogonal a ele. A saída do token 0 é a média ponderada das linhas de $V$ com esses pesos: $0{,}401\,(1,0) + 0{,}198\,(0,1) + 0{,}401\,(1,1) = (0{,}802;\; 0{,}599)$. O token 0 entrou como $(1, 0)$ e saiu contaminado pelos vizinhos com quem se parece. A contaminação é o mecanismo, e o exemplo inteiro cabe numa conferência de cinco minutos com lápis.

Cada token sai desta operação como uma média ponderada dos values de todos os tokens válidos da sequência, com pesos que somam um. O token “banco” numa frase sobre juros pode sair carregando informação de “juros” e “conta”, e o mesmo “banco” numa frase sobre praças pode carregar informação de “praça” e “sentar”. É a polissemia da Seção 3 sendo tratada por seis multiplicações de matrizes: as três projeções $XW_Q$, $XW_K$ e $XW_V$, os dois produtos $QK^\top$ e $AV$, nos quais $A$ contém os pesos de atenção, e a projeção final por $W_O$. As 16 cabeças fazem isso em paralelo, cada uma com seus próprios pesos e capaz de aprender combinações diferentes; seus resultados são concatenados de volta à dimensão $d = 1024$ e projetados por $W_O \in \mathbb{R}^{d \times d}$.

Segue-se a rede feed-forward, aplicada a cada token independentemente: duas transformações lineares com a não linearidade GELU entre elas, $\operatorname{FFN}(x) = \operatorname{GELU}(x W_1 + b_1) W_2 + b_2$, com $W_1 \in \mathbb{R}^{1024 \times 4096}$ e $W_2 \in \mathbb{R}^{4096 \times 1024}$. A camada expande a dimensão por um fator 4 e volta. Cada uma das duas subcamadas, atenção e FFN, vem embrulhada em uma conexão residual seguida de normalização de camada: a saída é $\operatorname{LayerNorm}_{\gamma,\beta}(x + \operatorname{Subcamada}(x))$. A normalização primeiro transforma as $d$ componentes para média aproximadamente $0$ e variância aproximadamente $1$, usando um $\varepsilon$ para estabilidade, e depois aplica o ganho aprendido $\gamma$ e o viés aprendido $\beta$. Por causa desses dois parâmetros, a saída final não precisa conservar média $0$ nem variância $1$. O residual dá ao gradiente um caminho direto através das 24 camadas; a normalização controla a escala das ativações ao longo delas.

Fechemos a subseção com a contagem de parâmetros, que é o teste de que entendemos a arquitetura. Por camada: as quatro projeções da atenção ($W_Q, W_K, W_V, W_O$, cada uma $1024 \times 1024$ mais o viés) somam cerca de $4{,}2$ milhões; as duas matrizes da FFN ($1024 \times 4096$ e $4096 \times 1024$, com vieses) somam cerca de $8{,}4$ milhões; os ganhos e vieses das duas normalizações de camada são troco. Total: $\approx 12{,}6$ milhões por camada, $\approx 302$ milhões pelas 24 camadas. Somando os 256 milhões da tabela de vocabulário, cerca de $8{,}4$ milhões de embeddings posicionais e as cabeças adicionais de recuperação, chegamos aos aproximadamente 568 milhões documentados do modelo. As contas fecham; a arquitetura é exatamente o que eu disse que seria.

Lendo assim, parece fácil, mas me deu uma dor de cabeça sem igual (sábado quase todo e domingo inteiro corrigindo, alterando e escrevendo código). Mesmo usando os assistentes de inteligência artificial para ajudar no código, deu um trabalho louco e completamente inesperado. Juro! Quando eu comecei, achei que um prompt resolveria tudo. Ledo engano!

6. Do Token ao Texto: o Pooling

As 24 camadas produzem uma matriz $H \in \mathbb{R}^{L \times d}$: um vetor contextualizado por token. O contrato da Seção 3, porém, pede um único vetor por texto. A operação que colapsa $L$ vetores em um chama-se pooling, e o BGE-M3, no modo que usamos, resolve-a com elegância: toma apenas a primeira linha de $H$, o vetor final do token [CLS], e a normaliza:

\[E(\text{texto}) \;=\; \frac{h_{\text{[CLS]}}}{\lVert h_{\text{[CLS]}} \rVert} \;\in\; \mathbb{R}^{1024}.\]

Por que a primeira linha carregaria o texto inteiro? A atenção da Seção 5.2 distingue posições graças aos embeddings posicionais, mas permite que o [CLS] consulte todos os tokens válidos em todas as camadas. No modo denso, a pontuação usada pela perda é calculada a partir do estado final desse marcador, e o gradiente o esculpe, camada após camada, num agregador. Ele resume o texto porque foi treinado para isso, não porque a primeira posição possua alguma propriedade geométrica especial.

O [CLS] é um repórter posicionado na primeira fila cujo único trabalho, aprendido sob pressão, é sair da sala com a síntese da reunião. A normalização final não é detalhe: com $\lVert E(\cdot) \rVert = 1$, a identidade da Seção 4 vale, e comparar textos por produto interno, cosseno ou distância euclidiana torna-se a mesma decisão.

Duas notas importantes completam o retrato do modelo:

Primeira: o checkpoint original do BGE-M3 estende o limite do XLM-RoBERTa para 8.192 tokens e passa por um pré-treinamento RetroMAE. Nele, o encoder recebe uma versão mascarada do texto e produz o vetor da sentença; um decodificador raso recebe esse vetor e outra versão, mais agressivamente mascarada, e tenta reconstruir os tokens ocultos. Portanto, a reconstrução não parte apenas do vetor, mas o obriga a carregar informação útil para recuperação.

Segunda: o “M3” do nome BGE-M3 anuncia multi-lingual, multi-functionality, multi-granularity, e pouca modéstia. A multifuncionalidade merece um parágrafo, porque diz respeito a uma escolha que fiz. Nesta tarde infernal de domingo.

Além do vetor denso do [CLS], o que usamos, o BGE-M3 produz, no mesmo passe pelo encoder, outras duas representações. Uma esparsa: pesos aprendidos apenas para as subpalavras presentes no texto, formando implicitamente um vetor do tamanho do vocabulário cuja maioria das posições vale zero. É a busca léxica reconstruída dentro do Transformer, útil para casar termos raros e nomes próprios que o vetor denso pode diluir. A outra é uma representação multivetor, na linhagem do ColBERT: um vetor projetado por token, comparado por interação tardia entre consulta e documento. Esse modo pode melhorar a qualidade de recuperação, como mostram os experimentos do BGE-M3, mas é proporcionalmente mais caro de armazenar e consultar. Nosso sistema usa apenas o modo denso, e a razão é de engenharia: o índice da Seção 9 armazena um vetor de 1024 dimensões por trecho, e as alternativas exigiriam estruturas e operações que um blog não amortiza. A leitora fica, porém, com o mapa: quando a busca densa falhar num nome próprio raro, ela saberá qual modo do mesmo modelo pode ajudar.

O que me lembra que os artigos de BERT ficaram no rascunho. Não resolvi os exercícios nem fiz os artefatos de laboratório. Dia destes tenho que publicar estes artigos. A matéria deixou de ser minha e, consequentemente, parei de lecionar processamento de linguagem natural. Ou seja, só publiquei a primeira parte da disciplina.

7. O Treinamento: Geometria por Contraste

Falta o passo conceitual mais importante. A arquitetura das Seções 5 e 6 produz algum vetor por texto. Mas nada nela garante que textos parecidos recebam vetores parecidos. E aqui está o pulo do gato: essa propriedade não é da arquitetura; é do treinamento contrastivo, e a função de perda que o governa merece dedução completa.

O dado de treino é um par $(q, p^{+})$: uma consulta e uma passagem sabidamente relevante, como título e corpo, instrução e resposta ou sentenças paralelas. Na etapa não supervisionada, os autores reuniram 1,2 bilhão de pares textuais de 194 línguas; etapas posteriores acrescentaram dados supervisionados, sintéticos e negativos difíceis. Dentro de um batch com outras passagens $p_1, \dots, p_m$ (os negativos), a forma básica da perda InfoNCE para o modo denso é:

\[\mathcal{L} \;=\; -\log \frac{\exp\!\big(\cos(E(q),\, E(p^{+})) / \tau\big)}{\exp\!\big(\cos(E(q),\, E(p^{+})) / \tau\big) + \sum_{j=1}^{m} \exp\!\big(\cos(E(q),\, E(p_j)) / \tau\big)}\]

na qual $\tau > 0$ é a temperatura, um escalar pequeno (tipicamente entre $0{,}01$ e $0{,}1$) que dilata as diferenças de similaridade antes do softmax. A leitora reconhece a estrutura: é a mesma entropia cruzada do artigo sobre a probabilidade da linguagem, aplicada a uma “classificação” cuja classe correta é a passagem verdadeira e cujas classes erradas são os negativos do batch. Minimizar $\mathcal{L}$ empurra $\cos(E(q), E(p^{+}))$ para cima e cada $\cos(E(q), E(p_j))$ para baixo: aproxima pares verdadeiros, afasta pares falsos. Repetida sobre muitos lotes e etapas de treinamento, a pressão esculpe o espaço inteiro: a única forma de o modelo pagar a perda barata em todos os pares simultaneamente é organizar os vetores por significado. A geometria não foi programada; foi extorquida.

A temperatura controla a dureza da extorsão, e vale deduzir como. Com $\tau = 1$, uma diferença de similaridade de $0{,}1$ entre a passagem certa e um negativo vira uma razão de $e^{0{,}1} \approx 1{,}11$ entre exponenciais, quase indiferente. Com $\tau = 0{,}02$, a mesma diferença vira $e^{5} \approx 148$: o softmax passa a punir com força os negativos que chegam perto. Temperaturas baixas concentram o gradiente nos negativos difíceis, as passagens que se parecem com a certa sem o ser, que são exatamente os casos que separam uma busca boa de uma busca decorativa. E esta busca vai ficar boa!

O multilinguismo, prometido na Seção 5.1, fecha-se aqui: os dados incluem sentenças paralelas e correspondências entre línguas. A mesma pressão contrastiva que aproxima “como funciona atenção” de um parágrafo relevante também aproxima textos semanticamente correspondentes escritos em português e inglês, organizando as línguas num espaço compartilhado. Isso não garante que qualquer tradução receba vetores idênticos, mas permite a recuperação entre línguas observada nos testes do modelo. Para este blog, que ainda escrevo principalmente em português, sobre uma literatura técnica em inglês e com termos das duas línguas misturados no mesmo parágrafo, essa propriedade não é luxo. Novamente, é coisa de quem tem os pés no chão. Esse foi o requisito que decidiu a escolha do modelo. Lembra? Eu falei alguma coisa sobre pobre escriba?

Por fim, o toque particular do BGE-M3: a autodestilação (self-knowledge distillation).

Os três modos da Seção 6, denso, esparso e multivetor, são treinados juntos, e as pontuações combinadas dos três servem de professor para cada um individualmente: cada modo aprende também a imitar o comitê do qual participa. As ablações publicadas pelos autores mostram que esse regime melhora o modo denso em relação ao treinamento sem autodestilação. Como intuição, a leitora pode pensar nisso como parte da sensibilidade léxica do modo esparso influenciando o vetor denso, o TF-IDF da Seção 2 assombrando, beneficamente, o vetor que o aposentou.

7.1 Laboratório: A Temperatura da Extorsão

O laboratório abaixo dá corpo à perda da Seção 7. A consulta $q$ permanece fixa, enquanto a passagem positiva $p^+$ e três negativas podem ser arrastadas sobre o círculo unitário. Cada movimento altera o cosseno, o logit dividido por $\tau$, a probabilidade produzida pelo softmax e a perda $-\log P(p^+\mid q)$. A última coluna mostra $\partial\mathcal{L}/\partial\cos$: o sinal negativo da passagem positiva manda aumentar sua similaridade; os sinais positivos das negativas mandam diminuí-las.

Comece pelas duas predefinições com margem $0{,}1$. Em $\tau=1$, a razão entre as exponenciais vale apenas $e^{0{,}1}\approx1{,}11$; em $\tau=0{,}02$, a mesma geometria produz $e^5\approx148$. Depois arraste o negativo n1 até quase empatar com $p^+$ e observe onde a pressão do treinamento se concentra. A geometria continua a mesma; o termostato decide quanto ela dói.

8. Engenharia I: Fatiar o Acervo

A matemática está completa; começa a engenharia, e com ela as decisões que não têm teorema, só trade-off. A primeira: o que exatamente vetorizar? Vetorizar cada artigo inteiro é tentador e errado por dois motivos mensuráveis.

Primeiro, a resolução: o vetor único de um artigo de 10 mil palavras que dedica três parágrafos a softmax precisa representar também os outros 97% do conteúdo, e uma consulta sobre softmax pode casar mal com essa representação global.

Segundo, o limite e o custo: o checkpoint original aceita 8.192 tokens, mas o artigo sobre GEMM desta série tem 10.759 palavras antes mesmo da tokenização em subpalavras. O endpoint hospedado da Cloudflare hoje anuncia 60.000 tokens e pode receber o artigo inteiro, mas depender dessa extensão amarraria o indexador ao serviço e faria a atenção processar uma sequência muito maior. Fatiar continua reduzindo o custo de inferência e preservando compatibilidade com o checkpoint original.

A solução adotada é o chunking: fatiar cada artigo em trechos de tamanho controlado e vetorizar cada trecho. Nosso indexador mira trechos de aproximadamente 300 palavras, fruto de experiências anteriores e de uma folga confortável sob ambos os limites de tokens. Aplicamos duas regras de civilidade.

A primeira: fronteiras de trecho respeitam fronteiras de parágrafo, e parágrafos gigantes são divididos por sentenças; um trecho que começa no meio de uma dedução envenena seu próprio vetor.

A segunda: o título do artigo é prefixado a cada trecho antes da vetorização, porque um parágrafo solto que diz “a divisão por $\sqrt{d_k}$ evita a saturação” ganha muito em identidade quando chega ao encoder acompanhado de “Prestando Atenção: a divisão por…”. O texto também é limpo antes do corte: blocos de código, fórmulas LaTeX e marcação são removidos porque este índice prioriza a prosa e porque essas estruturas consumiriam tokens sem benefício de recuperação previamente medido. Um bloco de CUDA no meio do trecho é, para esta versão, ruído com cheiro de assinatura.

A próxima versão vai dar um jeito de incluir o LaTeX e a matemática. Tive uma ou duas ideias para isso.

Os números do acervo real, medidos novamente pelo indexador, são 62 artigos publicados, fatiados em 935 trechos. Isso representa uma média de 15 trechos por artigo, com o artigo de GEMM contribuindo com 42. Cada trecho vira um vetor de 1024 dimensões, o que dá $935 \times 1024 = 957\,440$ dimensões ou 3,83 MB de coordenadas em ponto flutuante de 32 bits. Esse valor não é o tamanho completo do índice: exclui metadados e a estrutura de busca mantida pelo Vectorize. Também é maior, não menor, que os cerca de 155 kB dos dois arquivos JavaScript da solução Lunr antes da compressão HTTP. A vantagem correta está no lugar do custo: os vetores permanecem no servidor, e a visitante não baixa o acervo nem monta o índice no navegador. As coordenadas não contêm prosa legível, mas os metadados guardam URL, título e resumo para compor a resposta.

Nota mental: rever o artigo de GEMM, quando terminar o livro de programação competitiva.

A atualização é incremental, e o mecanismo cabe numa frase com hash dentro: o indexador guarda, por artigo, o SHA-256 da URL, do título, da classificação de acesso e do texto limpo. A cada deploy, reprocessa apenas os artigos cujo hash mudou. Um artigo novo, revisado, removido ou com metadados relevantes alterados custa chamadas de API; os demais custam uma comparação de strings local.

Publicar um post novo neste blog dispara, sem intervenção adicional, sua entrada no índice quando as credenciais estão configuradas: depois de compilar o site, o script de deploy invoca o indexador. Essa etapa é deliberadamente não fatal; se a API falhar, o site ainda é publicado e o índice fica uma edição atrasado até a próxima execução bem-sucedida.

O código vem depois da matemática, como manda a casa e, quebrando o costume da série de escrever C++, o trecho abaixo é Python, porque é o código de produção real do indexador deste blog, não uma ilustração.

Isso porque tive a brilhante ideia de herdar um código da web para economizar tempo. Nunca dá certo; eu nunca aprendo. A segunda versão será em C++, talvez nas férias de fim de ano.

O coração do fatiamento e do incremento pode ser visto no fragmento a seguir:

def chunk_text(text, target=300, hard_max=450):
    """Agrupa parágrafos; os muito longos são divididos por sentenças."""
    paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()]
    chunks, current, current_words = [], [], 0
    for para in paragraphs:
        words = para.split()
        if len(words) > hard_max:
            sentences = re.split(r"(?<=[.!?])\s+", para)
            para_parts, buf, buf_w = [], [], 0
            for sent in sentences:
                sw = len(sent.split())
                if buf_w + sw > target and buf:
                    para_parts.append(" ".join(buf))
                    buf, buf_w = [], 0
                buf.append(sent)
                buf_w += sw
            if buf:
                para_parts.append(" ".join(buf))
        else:
            para_parts = [para]
        for part in para_parts:
            pw = len(part.split())
            if current_words + pw > target and current:
                chunks.append("\n\n".join(current))
                current, current_words = [], 0
            current.append(part)
            current_words += pw
    if current:
        chunks.append("\n\n".join(current))
    return chunks

# Incremento: URL, título, acesso e texto participam do hash.
digest = hashlib.sha256(
    ("|".join([post["url"], post["title"],
               post.get("access", "closed")]) + "\n" + text)
    .encode("utf-8")
).hexdigest()
if state["posts"].get(source, {}).get("hash") == digest:
    continue
embed_inputs = [post["title"] + "\n\n" + c for c in chunks]

A atenta leitora deve notar o prefixo do título na última linha e os quatro componentes do hash. A decisão didática do parágrafo anterior virou uma concatenação, e não há chamada de rede até que os 64 algarismos hexadecimais do SHA-256 discordem do estado salvo. A economia da indexação incremental não está num algoritmo esperto; está em comparar o resumo antes de gastar dinheiro.

9. Engenharia II: o Índice Vetorial

Com o acervo vetorizado, buscar é, em princípio, trivial: dado o vetor $E(q)$ da consulta, calcular o produto interno contra cada um dos $N$ vetores do índice e devolver os maiores. O custo da força bruta é $N \cdot d$ multiplicações-acumulações por consulta. Com $N = 935$ e $d = 1024$, são $957\,440$ FMAs, cerca de 1,91 milhão de FLOPs pela convenção de contar uma multiplicação e uma adição por FMA. O conjunto de coordenadas ocupa 3,83 MB; uma implementação vetorizada e contígua em CPU pode percorrê-lo em poucos milissegundos, embora a latência completa dependa de memória, linguagem e ordenação dos resultados. Confessemos, portanto, sem drama: para o acervo atual deste blog, a busca exata seria perfeitamente viável.

O sistema, contudo, usa um índice vetorial de verdade: o Cloudflare Vectorize. Esta decisão merece defesa honesta em vez de marketing.

Do ponto de vista da praticidade, o Vectorize resolve, além da busca, o armazenamento durável, a atualização incremental via API, os metadados por vetor e a integração com o resto da arquitetura da Seção 10. Não fosse isso, eu teria que construir tudo à mão em volta da nossa força bruta.

Além da praticidade, existem os nossos princípios. A busca vetorial em escala é um problema rico, e a leitora deve conhecer o mapa mesmo usando a versão gerenciada. Quando $N$ chega aos milhões, a força bruta $O(N \cdot d)$ deixa de ser troco de ping, e a resposta clássica são os índices de vizinhos mais próximos aproximados (ANN, de approximate nearest neighbors), que trocam uma fração de exatidão por ordens de magnitude de velocidade. As duas famílias dominantes são os índices de partição, como o IVF, que agrupa os vetores em células de Voronoi por meio de k-means e só examina as células mais próximas da consulta, e os grafos navegáveis, como o HNSW, que constrói um grafo de vizinhanças em camadas hierárquicas e responde consultas descendo o grafo por busca gulosa, com custo empírico aproximadamente logarítmico em muitos conjuntos de dados. A Cloudflare não documenta publicamente qual estrutura o Vectorize usa sob o capô. Sua documentação de consulta informa, porém, que a pontuação aproximada é o padrão e que returnValues: true ativa a pontuação de alta precisão sobre os valores originais. Nosso código pede metadados, não os valores; portanto, os resultados são aproximados mesmo com apenas 935 vetores. O acervo pequeno reduz a necessidade prática da aproximação, mas não a transforma em busca exata.

A Cloudflare não caiu do céu. Além de ser a CDN que estou usando no blog, o Vectorize foi emocionalmente defendido em um thread no X por um desenvolvedor que, nesta semana, foi contratado pela Anthropic. Fiquei com o Vectorize na cabeça e, hoje, não tive dúvidas.

Cada vetor entra no índice com metadados: URL, título e um resumo curto do artigo; e com um identificador determinístico derivado da URL e da posição do trecho. Isso faz com que cada reindexação seja uma substituição idempotente em vez de uma duplicação. A consulta pede os $k = 8$ vizinhos mais próximos; como um artigo bom sobre o assunto tende a colocar vários trechos entre os oito, o sistema deduplica por URL ficando com o melhor trecho de cada artigo, e devolve até cinco artigos. É a diferença, herdada de qualquer sistema de recuperação decente, entre recuperar trechos e apresentar documentos.

9.1 Laboratório: O Preço da Aproximação

O laboratório seguinte reduz o índice a 32 vetores unitários e fixa $k=3$, pequeno o bastante para que cada comparação caiba na tela. A força bruta visita os 32 pontos e fornece a resposta exata. O modo IVF divide o círculo em quatro células e permite escolher quantas serão examinadas; o modo HNSW didático entra por uma camada esparsa e percorre um grafo de vizinhanças com orçamento limitado. Esta visualização de duas camadas preserva o mecanismo central do HNSW, mas não pretende reproduzir todos os detalhes de construção, poda e busca de uma biblioteca de produção.

Arraste a consulta até uma fronteira do IVF e reduza o número de células investigadas. Depois faça o mesmo no grafo com poucas visitas. Os círculos vermelhos marcam vizinhos exatos que a aproximação deixou para trás; recall@3 mede a fração recuperada. Aumentar o orçamento tende a restaurar a resposta, ao preço de mais comparações. É o contrato dos índices ANN transformado numa conta que pode falhar diante da leitora, como toda boa conta de engenharia deve poder fazer.

10. Engenharia III: a Borda, as Cicatrizes

Falta o palco. Este blog é um site estático: Markdown compilado pelo Jekyll em HTML, servido pelo Cloudflare Pages a partir de um repositório. Não há servidor de aplicação, e essa ausência é uma característica, uma opção de design, não uma limitação. A busca semântica, porém, precisa executar código por consulta: vetorizar a pergunta e consultar o índice. A solução é rodar esse código na borda, na infraestrutura distribuída da Cloudflare, na forma de uma function de poucas dezenas de linhas. Esta function recebe a consulta, valida o tamanho, chama o Workers AI como serviço gerenciado para obter $E(q)$ do mesmo BGE-M3 da indexação (a simetria é obrigatória: consulta e acervo precisam habitar o mesmo espaço vetorial, vetorizados pelo mesmo modelo), consulta o Vectorize, deduplica e responde um JSON com títulos, URLs e resumos. A plataforma decide onde atender a inferência, portanto não há garantia de que a GPU esteja no mesmo local físico de onde o HTML foi servido. O núcleo, escrito no JavaScript usado em produção, pode ser visto em:

const ai = await env.AI.run("@cf/baai/bge-m3", { text: [q] });
const embedding = ai.data[0];                    // E(q), 1024 dimensoes
const res = await env.SEARCH_INDEX.query(embedding, {
  topK: 8, returnMetadata: "all",
});
// varios trechos podem apontar para o mesmo artigo:
// deduplicar por URL, mantendo o melhor score
const byUrl = new Map();
for (const m of res.matches) {
  const prev = byUrl.get(m.metadata.url);
  if (!prev || m.score > prev.score) byUrl.set(m.metadata.url, m);
}

A latência medida de ponta a ponta, da minha cadeira: cerca de 2,8 segundos na primeira consulta e 90 milissegundos em consultas repetidas, servidas por um cache de cinco minutos na própria borda. Como o cache evita tanto a inferência quanto a consulta ao índice, essa diferença mede o caminho completo e não permite atribuir ao encoder uma parcela exata da latência. E olha que eu testei de verdade.

Prometemos cicatrizes, e um artigo que só contasse os acertos seria propaganda. Duas falhas de produção valem o registro, porque ambas ensinam.

A primeira: o backend do Workers AI respondeu ocasionalmente HTTP 408 durante nossas execuções, e a primeira versão do indexador tratava esse código como erro definitivo de cliente, abortando uma etapa depois de dezenove minutos de trabalho. O código 408 significa que o servidor não concluiu a requisição no tempo esperado; a observação de carga explica o contexto do episódio, não prova a causa interna do serviço. Dentes rangendo de ódio. Levei meia tarde para perceber isso.

O diagnóstico ensina a taxonomia que os manuais de HTTP raramente sublinham: entre os erros 4xx, que em geral significam “a culpa é sua, não insista”, o 408 e o 429 são exceções que podem ser transitórias. Estes dois bandidos podem significar “agora não”, e a resposta adotada é insistir, com recuo exponencial entre tentativas e lotes menores por chamada. Depois da correção, a indexação atravessa os 408 com até cinco tentativas por lote e termina; o estado incremental da Seção 8 garante que, no pior caso, uma interrupção retoma de onde parou, sem repetir artigos já persistidos. Demora: a carga completa observada gastou quase duas horas. Um jogo de final de Copa inteiro.

A segunda cicatriz é mais fina. Nos primeiros testes ao vivo, o endpoint respondia. Rápido, confiante e exatamente os mesmos resultados para qualquer consulta.

Ali estava eu prostrado diante de um oráculo que havia decidido a resposta antes de ouvir a pergunta.

Roubou-me a outra metade da tarde de domingo. Demorei para lembrar por que eu uso a Cloudflare.

A causa: uma regra de cache da CDN, anterior ao projeto, configurada para armazenar tudo por duas horas ignorando a query string. Para páginas HTML estáticas, otimização legítima; para um endpoint em que a query string É a pergunta, veneno mortal.

A primeira consulta após cada expiração era respondida de verdade e armazenada; as demais, durante duas horas, recebiam a resposta da primeira. A correção teve as duas pontas que toda correção de cache exige: uma regra de exceção na CDN para o caminho da busca e o endpoint endurecido para declarar no-store ao mundo exterior enquanto mantém, internamente, um cache por consulta cuja chave inclui o que a chave de fora ignorava. A lição, que transcende CDNs: cache compartilhado com chave errada não é cache lento nem cache frio. É um sistema que responde à pergunta de outra pessoa, com a pior propriedade que um sistema pode ter no mundo moderno: convicção.

11. As Contas do Custo Zero

Prometemos custo fixo zero no primeiro parágrafo; um artigo desta série não encerra sem conferir a promessa contra os números atuais. Três recursos são consumidos, cada um com sua franquia gratuita. No Vectorize, as $957\,440$ dimensões vetoriais armazenadas ocupam 19,1% do limite de 5 milhões, de modo que o acervo pode crescer cerca de 5,2 vezes se mantiver a mesma média de trechos por artigo. A franquia mensal de consulta é de 30 milhões de dimensões vetoriais. Pela fórmula publicada pela Cloudflare, os 935 vetores armazenados e os vetores enviados nas consultas entram nessa conta; depois da carga atual, restam aproximadamente 28.361 consultas de 1024 dimensões por mês, cerca de 945 por dia num mês de 30 dias. Acertos no cache da Seção 10 não consultam o Vectorize e não consomem essa franquia.

A inferência do encoder usa outra unidade. O Workers AI concede 10 mil neurons diários e cobra atualmente 1.075 neurons por milhão de tokens de entrada no @cf/baai/bge-m3. A franquia equivale, portanto, a aproximadamente 9,3 milhões de tokens de entrada por dia; uma consulta comum, com dezenas de tokens, consome uma pequena fração de um neuron. A carga inicial do acervo coube com folga no orçamento observado, e a indexação incremental da Seção 8 existe precisamente para não repeti-la a cada publicação. Esses limites são propriedades comerciais atuais do serviço, não constantes matemáticas, mas hoje nenhuma fatura nova chega por causa da busca.

12. Em que ponto estamos

Antes da síntese, a curiosa leitora pode abrir /busca/ e interrogar a aplicação completa desta matemática. Comece por “por que vendedores de carros usados enganam” e confira qual artigo responde, notando que nenhuma das palavras da consulta precisa aparecer nele. Os laboratórios das Seções 4, 7 e 9 mostraram a trajetória em escalas sucessivas: medir um ângulo, esculpir os ângulos pelo treinamento e evitar medir todos eles durante a recuperação. A busca em produção repete essa trajetória em 1024 dimensões, sobre os 935 trechos do acervo.

Treze artigos de matemática e um de produção depois, a síntese cabe numa frase que a série carregará: um modelo de linguagem transforma significado em geometria, e buscar é medir ângulos. A atenção da Seção 5 é feita de produtos internos entre tokens; o treinamento da Seção 7 é uma disciplina de ângulos entre textos; a busca da Seção 9 usa novamente produtos internos, agora servidos pela infraestrutura distribuída da Cloudflare. A esforçada leitora que chegou até aqui não aprendeu apenas a usar uma busca semântica. Aprendeu por que ela funciona, a diferença entre operar uma máquina e possuí-la. No próximo artigo, a série voltará ao decoder e à geração; a busca ficará no menu, trabalhando, como convém à matemática quando cresce e arruma emprego.

Referências

BA, J. L.; KIROS, J. R.; HINTON, G. E. Layer Normalization. arXiv preprint arXiv:1607.06450, 2016. Disponível em: https://arxiv.org/abs/1607.06450.

CHEN, J.; XIAO, S.; ZHANG, P.; LUO, K.; LIAN, D.; LIU, Z. BGE M3-Embedding: Multi-Lingual, Multi-Functionality, Multi-Granularity Text Embeddings Through Self-Knowledge Distillation. arXiv preprint arXiv:2402.03216, 2024. Disponível em: https://arxiv.org/abs/2402.03216.

CLOUDFLARE. bge-m3 (BAAI). Workers AI Docs, 2026. Disponível em: https://developers.cloudflare.com/workers-ai/models/bge-m3/.

CLOUDFLARE. Pricing: Cloudflare Vectorize. Cloudflare Docs, 2026. Disponível em: https://developers.cloudflare.com/vectorize/platform/pricing/.

CLOUDFLARE. Pricing: Workers AI. Cloudflare Docs, 2026. Disponível em: https://developers.cloudflare.com/workers-ai/platform/pricing/.

CLOUDFLARE. Query Vectors. Cloudflare Vectorize Docs, 2026. Disponível em: https://developers.cloudflare.com/vectorize/best-practices/query-vectors/.

CONNEAU, A. et al. Unsupervised Cross-lingual Representation Learning at Scale. Proceedings of the 58th Annual Meeting of the Association for Computational Linguistics, p. 8440–8451, 2020. Disponível em: https://aclanthology.org/2020.acl-main.747/.

DEVLIN, J.; CHANG, M.-W.; LEE, K.; TOUTANOVA, K. BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding. Proceedings of NAACL-HLT 2019, p. 4171–4186, 2019. Disponível em: https://aclanthology.org/N19-1423/.

FIRTH, J. R. A Synopsis of Linguistic Theory, 1930–1955. In: Studies in Linguistic Analysis. Oxford: The Philological Society, p. 1–32, 1957.

HARRIS, Z. S. Distributional Structure. Word, v. 10, n. 2–3, p. 146–162, 1954. Disponível em: https://doi.org/10.1080/00437956.1954.11659520.

HENDRYCKS, D.; GIMPEL, K. Gaussian Error Linear Units (GELUs). arXiv preprint arXiv:1606.08415, 2016. Disponível em: https://arxiv.org/abs/1606.08415.

JÉGOU, H.; DOUZE, M.; SCHMID, C. Product Quantization for Nearest Neighbor Search. IEEE Transactions on Pattern Analysis and Machine Intelligence, v. 33, n. 1, p. 117–128, 2011. Disponível em: https://doi.org/10.1109/TPAMI.2010.57.

KARPUKHIN, V. et al. Dense Passage Retrieval for Open-Domain Question Answering. Proceedings of EMNLP 2020, p. 6769–6781, 2020. Disponível em: https://aclanthology.org/2020.emnlp-main.550/.

KHATTAB, O.; ZAHARIA, M. ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT. Proceedings of SIGIR 2020, p. 39–48, 2020. Disponível em: https://doi.org/10.1145/3397271.3401075.

KUDO, T.; RICHARDSON, J. SentencePiece: A Simple and Language Independent Subword Tokenizer and Detokenizer for Neural Text Processing. Proceedings of the 2018 Conference on Empirical Methods in Natural Language Processing: System Demonstrations, p. 66–71, 2018. Disponível em: https://doi.org/10.18653/v1/D18-2012.

MACQUEEN, J. Some Methods for Classification and Analysis of Multivariate Observations. Proceedings of the Fifth Berkeley Symposium on Mathematical Statistics and Probability, v. 1, p. 281–297, 1967. Disponível em: https://digicoll.lib.berkeley.edu/record/113015?v=pdf.

MALKOV, Y. A.; YASHUNIN, D. A. Efficient and Robust Approximate Nearest Neighbor Search Using Hierarchical Navigable Small World Graphs. IEEE Transactions on Pattern Analysis and Machine Intelligence, v. 42, n. 4, p. 824–836, 2020. Disponível em: https://doi.org/10.1109/TPAMI.2018.2889473.

MIKOLOV, T.; CHEN, K.; CORRADO, G.; DEAN, J. Efficient Estimation of Word Representations in Vector Space. Proceedings of the International Conference on Learning Representations, 2013. Disponível em: https://arxiv.org/abs/1301.3781.

NATIONAL INSTITUTE OF STANDARDS AND TECHNOLOGY. Secure Hash Standard (SHS). FIPS PUB 180-4, 2015. Disponível em: https://doi.org/10.6028/NIST.FIPS.180-4.

OORD, A. van den; LI, Y.; VINYALS, O. Representation Learning with Contrastive Predictive Coding. arXiv preprint arXiv:1807.03748, 2018. Disponível em: https://arxiv.org/abs/1807.03748.

PORTER, M. F. An Algorithm for Suffix Stripping. Program, v. 14, n. 3, p. 130–137, 1980. Disponível em: https://doi.org/10.1108/eb046814.

REIMERS, N.; GUREVYCH, I. Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks. Proceedings of EMNLP-IJCNLP 2019, p. 3982–3992, 2019. Disponível em: https://aclanthology.org/D19-1410/.

ROBERTSON, S.; ZARAGOZA, H. The Probabilistic Relevance Framework: BM25 and Beyond. Foundations and Trends in Information Retrieval, v. 3, n. 4, p. 333–389, 2009. Disponível em: https://doi.org/10.1561/1500000019.

SALTON, G.; BUCKLEY, C. Term-weighting Approaches in Automatic Text Retrieval. Information Processing & Management, v. 24, n. 5, p. 513–523, 1988. Disponível em: https://doi.org/10.1016/0306-4573(88)90021-0.

STEELE, J. M. The Cauchy-Schwarz Master Class: An Introduction to the Art of Mathematical Inequalities. Cambridge: Cambridge University Press, 2004. Disponível em: https://doi.org/10.1017/CBO9780511817106.

VASWANI, A. et al. Attention Is All You Need. Advances in Neural Information Processing Systems, v. 30, p. 5998–6008, 2017. Disponível em: https://arxiv.org/abs/1706.03762.

XIAO, S.; LIU, Z.; SHAO, Y.; CAO, Z. RetroMAE: Pre-Training Retrieval-oriented Language Models Via Masked Auto-Encoder. Proceedings of EMNLP 2022, p. 538–548, 2022. Disponível em: https://aclanthology.org/2022.emnlp-main.35/.

(Updated: )