O Problema
A maioria das equipes recorre ao Elasticsearch ou Solr quando precisa de busca full-text. Sao ferramentas solidas, mas tem um custo: overhead da JVM, pausas de GC, complexidade de cluster e latencias de consulta que tipicamente ficam na faixa de 5–50ms para queries simples. Para muitas aplicacoes, isso e aceitavel.
Nos precisavamos de algo diferente. Nossos requisitos: latencia sub-milissegundo no p99, ranking hibrido combinando relevancia BM25 com similaridade semantica, e deploy como um unico binario — sem JVM, sem coordenador de cluster, sem YAML.
A resposta foi Rust.
Arquitetura Geral
O motor e estruturado como um conjunto de crates Rust modulares, cada um responsavel por uma unica preocupacao. A camada de API e Axum + Tokio. A camada de indexacao e recuperacao e construida sobre Tantivy. A busca semantica passa pelo Qdrant, e os resultados de ambos os caminhos sao fundidos via Reciprocal Rank Fusion (RRF).
Por que Tantivy
Tantivy e uma biblioteca de busca full-text escrita em Rust, inspirada no Apache Lucene. Diferente do Elasticsearch (que envolve o Lucene em uma camada JVM), Tantivy compila para codigo nativo com zero garbage collection. Nos da controle direto sobre o layout de memoria, o merge de segmentos e os pipelines de tokenizacao.
A vantagem chave: o IndexReader do Tantivy usa segmentos memory-mapped com
compartilhamento baseado em Arc. Multiplas threads de busca leem da mesma
memoria mapeada sem copiar ou travar. Por isso o throughput concorrente escala quase linearmente.
Ranking Hibrido com RRF
Combinamos dois sinais de ranking:
- BM25 do Tantivy — relevancia classica por frequencia de termos, rapida e deterministica
- Similaridade semantica do Qdrant — embeddings vetoriais para matching baseado em significado
Estes sao fundidos usando Reciprocal Rank Fusion, que combina listas ranqueadas sem exigir normalizacao de scores. O algoritmo e O(n) no numero de resultados e adiciona overhead desprezivel — menos de 1ms mesmo para 1.000 resultados.
// Fusao RRF — merge O(n) de listas ranqueadas fn rrf_fuse(bm25: &[DocScore], semantic: &[DocScore], k: f32) -> Vec<DocScore> { let mut scores: HashMap<DocId, f32> = HashMap::new(); for (rank, doc) in bm25.iter().enumerate() { *scores.entry(doc.id).or_default() += 1.0 / (k + rank as f32 + 1.0); } for (rank, doc) in semantic.iter().enumerate() { *scores.entry(doc.id).or_default() += 1.0 / (k + rank as f32 + 1.0); } let mut fused: Vec<_> = scores.into_iter().collect(); fused.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap()); fused }
Resultados dos Benchmarks
Todos os benchmarks foram executados em macOS x86_64, build release, com Criterion para validade estatistica. O corpus consiste em documentos sinteticos (conteudo educacional, metadados de musicas, registros SCADA) que refletem nosso perfil de carga em producao.
Throughput de Indexacao
Com corpus pequenos, o custo de setup (criacao de diretorio, alocacao do writer de 50MB) domina. Conforme o corpus cresce, o throughput se estabiliza em 21K docs/seg. Os modos serial e batch tem desempenho identico — a tokenizacao e I/O interna do Tantivy e o verdadeiro gargalo, nao o overhead de locks.
| Tamanho do Corpus | Metodo | Tempo | Docs/seg |
|---|---|---|---|
| 1K | serial | 2.41s | 416 |
| 1K | batch | 2.37s | 422 |
| 10K | serial | 2.43s | 4.117 |
| 10K | batch | 2.52s | 3.963 |
| 50K | serial | 2.51s | 19.881 |
| 50K | batch | 2.64s | 18.913 |
| 100K | serial | 4.76s | 21.006 |
| 100K | batch | 5.08s | 19.690 |
Latencia de Consultas
O numero chave: a latencia p99 fica abaixo de 1ms mesmo com 50K documentos. A latencia mediana com 50K docs e 234 microssegundos. Para contexto, uma query simples tipica do Elasticsearch em um corpus comparavel retorna em 5–15ms — aproximadamente 20–50x mais lento.
| Corpus | p50 | p95 | p99 | Media |
|---|---|---|---|---|
| 1K | 119us | 296us | 335us | 136us |
| 10K | 131us | 294us | 537us | 160us |
| 50K | 234us | 712us | 984us | 290us |
Com 984us no p99, sua query mais lenta (1 em cada 100) ainda fica abaixo de um milissegundo. Isso significa que voce pode adicionar processamento extra — re-ranking, personalizacao, logica de teste A/B — e ainda retornar resultados em menos de 5ms no total. Esse e o orcamento que o Elasticsearch usa apenas para a query de busca.
Throughput Concorrente
O throughput escala quase linearmente com workers concorrentes gracas a arquitetura
lock-free do IndexReader do Tantivy. Com 100 workers concorrentes, sustentamos
27.287 queries por segundo em um unico nodo.
| Workers | Total Queries | Tempo Total | Queries/seg |
|---|---|---|---|
| 1 | 100 | 20.9ms | 4.782 |
| 10 | 1.000 | 51.1ms | 19.566 |
| 50 | 5.000 | 211.9ms | 23.600 |
| 100 | 10.000 | 366.5ms | 27.287 |
Performance do Query Parser
O query parser processa termos simples, queries de frase, negacao e expressoes booleanas complexas — tudo abaixo de 103 microssegundos. Esse e overhead pre-busca; executa antes de tocar o indice.
| Tipo de Query | Exemplo | Latencia Media |
|---|---|---|
| Simples | fracciones matematicas | 76.7us |
| Frase | "tabla periodica" quimica | 74.0us |
| Negacao | volcanes -tectonica ciencias | 102.6us |
| Complexa | "sistema solar" -pluton type:educational | 85.2us |
Fusao de Ranking RRF
A etapa de ranking hibrido adiciona overhead desprezivel. Mesmo fundindo 1.000 resultados de ambos os pipelines BM25 e semantico leva menos de 650 microssegundos.
| Resultados para Fundir | Latencia Media |
|---|---|
| 10 | 6.0us |
| 100 | 58.3us |
| 500 | 317.6us |
| 1.000 | 647.6us |
Eficiencia de Memoria
O indice usa aproximadamente 766 bytes por documento em RSS. Um corpus de 50K documentos adiciona apenas 36MB ao footprint de memoria do processo. Compare com o Elasticsearch, onde um indice comparavel tipicamente requer 2–5KB por documento mais overhead do heap da JVM.
| Metrica | Valor |
|---|---|
| RSS antes da indexacao | 56 MB |
| RSS apos 50K docs | 93 MB |
| Delta | 36 MB |
| Bytes por documento | ~766 bytes |
Analise Profunda
Por que o Overhead de Locks e Zero
Um resultado surpreendente: a indexacao serial e batch tem desempenho identico. A intuicao diz que o modo batch deveria evitar contencao de locks, mas na pratica o gargalo e a tokenizacao e I/O interna do Tantivy — nao o write lock.
Tantivy usa um unico IndexWriter com um buffer interno de 50MB.
Os documentos sao tokenizados, analisados e escritos em segmentos em memoria antes de
serem descarregados para disco. O write lock e mantido apenas durante a breve chamada a commit(),
que dispara um flush de segmento. Durante a indexacao normal, o lock nao tem contencao.
Como as Leituras Concorrentes Escalam
O IndexReader do Tantivy cria instancias leves de Searcher
que compartilham os mesmos arquivos de segmento memory-mapped via Arc. Nenhum dado e copiado.
Cada thread de busca obtem seu proprio Searcher, mas todos leem das mesmas
paginas de memoria fisica. O page cache do SO faz o resto.
Isso e fundamentalmente diferente dos motores baseados em JVM, onde cada query aloca objetos no heap, aumentando a pressao de GC sob carga concorrente.
O Query Parser
Nosso query parser suporta operadores booleanos, queries de frase com "aspas",
queries por campo com sintaxe campo:valor, negacao com -termo,
e matching com wildcards. Compila para uma arvore Box<dyn Query> do Tantivy
em menos de 100 microssegundos independente da complexidade.
O parser usa uma abordagem de passagem unica: tokeniza o input, classifica cada token (limite de frase, prefixo de negacao, prefixo de campo, termo simples), e constroi a arvore de consulta de baixo para cima. Sem backtracking, sem ambiguidade. O(n) no comprimento do input.
RRF: O Simples Vence o Complexo
Consideramos modelos de learned-to-rank para combinar scores de BM25 e semanticos. O problema: requerem dados de treinamento, adicionam latencia de inferencia, e precisam de re-treinamento quando o corpus muda. RRF nao tem nenhum desses problemas.
A formula e trivial: para cada documento que aparece em qualquer lista ranqueada,
somar 1 / (k + rank) atraves de todas as listas. Ordenar por score. Pronto.
Com k=60 (a constante padrao), isso produz resultados competitivos com
modelos treinados em benchmarks padrao, a uma fracao da complexidade.
Conclusoes Chave
- Rust + Tantivy elimina o imposto JVM. Sem pausas de GC, sem bloat de heap, sem tempo de warmup. Seu p99 e seu p99 — nao p99-menos-pausas-de-GC.
- Latencia p99 sub-milissegundo e alcancavel. Com 50K documentos, nossa pior query (1 em cada 100) retorna em 984 microssegundos. Isso deixa orcamento para logica de aplicacao.
- Concorrencia escala sem esforco. A arquitetura mmap + Arc do Tantivy significa que voce nao precisa pensar em read locks. Apenas instancie mais Searchers.
- RRF e o default correto para busca hibrida. Sem treinamento, sem tuning, complexidade O(n), latencia sub-milissegundo. Comece aqui; recorra a LTR somente se precisar.
- 766 bytes por documento. Seu laptop pode indexar um milhao de documentos e ainda ter RAM de sobra.
Todos os benchmarks foram gerados usando um binario Rust dedicado (benchmark_report.rs)
que cria documentos sinteticos que correspondem ao nosso schema de producao, mede tempo wall-clock
com std::time::Instant, e calcula percentis de 100–10.000 amostras
por medicao. A suite de benchmarks Criterion (core_benchmarks.rs) fornece
validacao estatistica com intervalos de confianca. O codigo-fonte esta disponivel para auditoria.