Um ganho de 50x que você não consegue gastar
🇺🇸 Read in EnglishFaça uma pergunta a um bundle de conhecimento e o okf search lê todo conceito para respondê-la. Todo campo de todo arquivo, toda vez, não importa quantas vezes você perguntou algo parecido um minuto atrás. Num bundle de duas dúzias de conceitos você nunca vai notar. Em alguns milhares, vai.
O conserto óbvio é um índice invertido, e a okf 1.9.0 entrega um. Ele é de 44 a 56 vezes mais rápido por consulta e está desligado. Este post é a medição que levou até ali, os custos que o número de manchete esconde, e a única mudança arquitetural que inverteria o padrão.
O que o scan faz toda vez
A engine padrão lê o texto e procura o seu termo nele, pesando um acerto no título acima de um acerto no corpo. É isso.
Não há índice, o que significa que não há nada a construir, nada a invalidar e nada a manter em sincronia com os arquivos em disco. Toda consulta paga o preço cheio e nenhuma consulta paga setup. Essa propriedade acaba sendo o argumento inteiro, embora tenha sido preciso um benchmark para enxergar.
Um port com orçamento de pegada
minifts é um port em Ruby puro do MiniSearch, a engine JavaScript que a página do grafo já carrega no navegador. Ele traz um índice de verdade e ranqueamento BM25+.
Adicioná-lo não custou nada a você na hora de instalar, que era a condição para adicioná-lo: nenhuma extensão nativa, para que um gem install nunca precise de compilador; nenhuma árvore de dependências própria; e o mesmo piso Ruby 2.4 que a gem sempre segurou, para que ela continue instalando numa década de Rubies.
O port ainda compra algo que a CLI não teria de outro jeito: a página no navegador e a CLI em Ruby agora rodam a mesma engine na mesma versão, então um resultado que você acha na página do grafo e um resultado que você acha com okf search --engine index ranqueiam igual. Duas implementações de "ranqueado" que discordam seriam piores que uma implementação mais lenta.
A medição
A comparação foi montada para ser justa, não lisonjeira. Os documentos são o bundle @okf real resolvido via OKF::Registry; tamanhos maiores replicam esses conceitos reais, com ids e títulos únicos e corpos reais, então o corpus cresce sem virar sintético. O lado da okf chama o OKF::Bundle::Search de verdade, não uma reimplementação. O lado do minifts indexa os mesmos seis campos e espelha os pesos de campo da okf como boosts por campo, então ambos ranqueiam sobre o mesmo sinal. As consultas são tiradas do vocabulário mais frequente do próprio corpus, misturando consultas seletivas e comuns de um e dois termos, e uma linha de paridade imprime as contagens de resultado de cada engine, para que se veja que as duas estão de fato buscando.
Vazão é a carga completa de nove consultas por segundo, medida com benchmark-ips no Ruby 4.0.5:
| corpus (conceitos) | minifts | scan linear da okf | ganho |
|---|---|---|---|
| 23 (o bundle real) | 2040 /s | 42 /s | 48x |
| 250 | 221 /s | 3,9 /s | 56x |
| 1.000 | 51 /s | 1,0 /s | 52x |
| 4.000 | 10,5 /s | 0,24 /s | 44x |
O múltiplo se mantém quase constante porque as duas abordagens escalam linearmente aqui: o scan relê tudo, e o corpus replicado compartilha corpos, então termos comuns casam com uma fração grande dele. Num corpus com mais texto distinto a diferença aumentaria, porque o minifts toca só as posting lists que casam enquanto um scan sempre visita tudo.
Uma divulgação pertence àquela tabela. Ela mede vazão de carga, não distribuição de latência: são iterações por segundo do benchmark-ips, então respondem "quantas vezes este conjunto fixo de consultas por segundo" e não "como é o p99 sob concorrência". Os números do minifts também são anteriores a uma rodada de ajuste de alocação, então o custo de construção abaixo hoje é um pouco menor que o número que guiou a decisão, o que move o argumento no mesmo sentido e não contra ele.
O número que decidiu o padrão
Um ganho de 50x é um resultado forte e é a medida errada, o que levou um tempo constrangedor para enxergar.
O índice tem um custo que o scan nunca paga: ele precisa ser construído. Aproximadamente 2,8 s por 1.000 documentos de conceito, uns 11 s em 4.000. Amortizado por algumas dezenas de consultas isso é nada. Amortizado por uma consulta, é o tempo de execução inteiro.
E uma consulta é tudo que uma invocação de CLI chega a perguntar:
| em 1.000 conceitos | ponta a ponta | do qual construção do índice |
|---|---|---|
--engine index | 3,00 s | ~95% |
| scan padrão | 0,24 s | nenhum |
A engine que ganha toda consulta por 50x perde o comando por 12x. A vazão era real e o processo nunca viveu o bastante para gastá-la.
Então o padrão não se mexeu. --engine index e --fuzzy estão ali para quando você quer o ranqueamento ou a tolerância a erro de digitação e está disposto a pagar por isso, e o tradeoff está declarado no --help em vez de enterrado num benchmark que ninguém roda.
O que o índice custa quando você de fato o quer
Velocidade não é o único preço, e o resto dele passa fácil despercebido porque a engine continua devolvendo linhas.
Um índice casa palavras inteiras, e decide o que é uma palavra quebrando na pontuação. Então customer_id vira duas palavras, e 7.2.0 vira três. A consequência em que você de fato vai esbarrar é que buscar um pedaço no meio de uma palavra deixa de funcionar:
$ okf search @okf ustomer
Search — @okf · ustomer (2 of 24 concepts)
$ okf search @okf ustomer --engine index
Search — @okf · ustomer (0 of 24 concepts)
O ranqueamento também não salva isso: um conceito curto e denso em 7, 2 e 0 pode superar aquele que de fato diz 7.2.0.
O padrão não tem nenhum desses modos de falha, pela razão pouco glamorosa de que casar texto cru não tem noção de palavra para errar. É por isso que as duas engines são documentadas como um tradeoff e não como uma escada de qualidade: o índice ranqueia bem e casa tokens; o padrão casa texto e ranqueia de forma grosseira.
O único caso em que o índice é sem ambiguidade a escolha certa é aquele que o scan não consegue fazer de jeito nenhum:
$ okf search @okf serch # 0 of 24 concepts
$ okf search @okf serch --fuzzy # 13 of 24 concepts
O scan não está sendo pouco prestativo ali. Ele está reportando corretamente que nada no bundle contém aquelas letras naquela ordem.
A forma que muda a aritmética
Não há nada de lento no índice. O problema é inteiramente o processo em que ele vive, o que sugere que o conserto não é um índice mais rápido e sim um de vida mais longa.
É para lá que a okf está indo: um sidecar quente. Um processo residente pequeno que constrói o índice caro uma vez e o mantém em memória, com a CLI conversando com ele em vez de reconstruir do zero. O primeiro comando paga o que paga hoje. Todo comando depois dele pula os 95%, instalada nativamente ou rodando da imagem Docker do mesmo jeito.
Para ser claro sobre o status: nada disso foi entregue, e ainda não há números. As perguntas honestas em aberto são as que sempre vêm com um processo residente. Quando um índice em cache fica velho contra arquivos que mudaram por baixo dele? O que o inicia e o encerra, e o que acontece quando nada faz isso? Onde fica a fronteira de confiança, dado que a okf já trata um bundle desconhecido como conteúdo não confiável? Um sidecar que responde mal a isso é pior que os 3,00 s que ele poupa.
O que a 1.9.0 fez foi tornar isso possível sem outra reescrita. E já existe um leitor para o qual a aritmética corre no sentido oposto: uma ferramenta de vida longa que busca sem parar, como uma interface de terminal ou o servidor do grafo, é exatamente a carga em que o benchmark diz que um índice ganha, então essas pedem o índice e recebem. O sidecar é a mesma percepção aplicada à própria CLI. Torne o processo de vida longa e a engine rápida deixa de ser uma flag que você precisa justificar.
Onde isso deixa o padrão
Por ora, o okf search é exato e instantâneo, e esse é o comportamento certo para um comando que você roda uma vez. --fuzzy está ali para quando você está chutando uma grafia. --engine index está ali para quando você quer ranqueamento BM25+ ou paridade com o que a página do grafo mostra.
A medição que produziu esses padrões é reprodutível; o harness vive no repositório do minifts:
gem install benchmark-ips
ruby -Ilib benchmarks/okf_vs_minifts.rb 1000
Rode contra o seu próprio bundle antes de tomar qualquer número acima como fé. O interessante não é o ganho. É quanto do tempo de execução do seu comando a construção do índice responde, porque essa razão sozinha é o que decide se um processo residente vale ser construído, e é o número que a próxima release está mirando.