EN ES PT

Motor de Busqueda
Sub-Milisegundo en Rust

Como construimos un motor de busqueda hibrido BM25 + semantico que maneja 27K queries/seg con latencia p99 sub-milisegundo en un solo nodo. Arquitectura, benchmarks reales y lecciones aprendidas.

<1ms
Latencia p99
27K
Queries/seg
21K
Docs/seg Indexacion
766B
Por Documento

El Problema

La mayoria de los equipos recurren a Elasticsearch o Solr cuando necesitan busqueda full-text. Son herramientas solidas, pero tienen un costo: overhead de la JVM, pausas de GC, complejidad de cluster y latencias de consulta que tipicamente caen en el rango de 5–50ms para queries simples. Para muchas aplicaciones, eso es aceptable.

Nosotros necesitabamos algo diferente. Nuestros requisitos: latencia sub-milisegundo en p99, ranking hibrido combinando relevancia BM25 con similaridad semantica, y deploy como un solo binario — sin JVM, sin coordinador de cluster, sin YAML.

La respuesta fue Rust.

Arquitectura General

El motor esta estructurado como un conjunto de crates Rust modulares, cada uno responsable de una sola preocupacion. La capa API es Axum + Tokio. La capa de indexacion y recuperacion esta construida sobre Tantivy. La busqueda semantica pasa por Qdrant, y los resultados de ambos caminos se fusionan via Reciprocal Rank Fusion (RRF).

API
Axum HTTP Query Parser Auth Middleware
Busqueda
BM25 (Tantivy) Semantica (Qdrant) Fusion RRF
Indice
Indice Invertido Vector Store Merge de Segmentos
Storage
Archivos mmap Redis Cache Commit Log

Por que Tantivy

Tantivy es una libreria de busqueda full-text escrita en Rust, inspirada en Apache Lucene. A diferencia de Elasticsearch (que envuelve Lucene en una capa JVM), Tantivy compila a codigo nativo con cero garbage collection. Nos da control directo sobre el layout de memoria, el merge de segmentos y los pipelines de tokenizacion.

La ventaja clave: el IndexReader de Tantivy usa segmentos memory-mapped con comparticion basada en Arc. Multiples threads de busqueda leen de la misma memoria mapeada sin copiar ni lockear. Por eso el throughput concurrente escala casi linealmente.

Ranking Hibrido con RRF

Combinamos dos senales de ranking:

Estas se fusionan usando Reciprocal Rank Fusion, que combina listas rankeadas sin requerir normalizacion de scores. El algoritmo es O(n) en el numero de resultados y agrega overhead despreciable — menos de 1ms incluso para 1,000 resultados.

// Fusion RRF — merge O(n) de listas rankeadas
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 de Benchmarks

Todos los benchmarks se ejecutaron en macOS x86_64, build release, con Criterion para validez estadistica. El corpus consiste en documentos sinteticos (contenido educativo, metadata de canciones, registros SCADA) que reflejan nuestro perfil de carga en produccion.

Throughput de Indexacion

Con corpus pequenos, el costo de setup (creacion de directorio, alocacion del writer de 50MB) domina. A medida que el corpus crece, el throughput se estabiliza en 21K docs/seg. Los modos serial y batch rinden identico — la tokenizacion e I/O interna de Tantivy es el verdadero cuello de botella, no el overhead de locks.

Tamano CorpusMetodoTiempoDocs/seg
1Kserial2.41s416
1Kbatch2.37s422
10Kserial2.43s4,117
10Kbatch2.52s3,963
50Kserial2.51s19,881
50Kbatch2.64s18,913
100Kserial4.76s21,006
100Kbatch5.08s19,690

Latencia de Consultas

El numero clave: la latencia p99 se mantiene bajo 1ms incluso con 50K documentos. La latencia mediana a 50K docs es 234 microsegundos. Para contexto, una query simple tipica de Elasticsearch en un corpus comparable retorna en 5–15ms — aproximadamente 20–50x mas lento.

Corpusp50p95p99Media
1K119us296us335us136us
10K131us294us537us160us
50K234us712us984us290us
Por que importan los microsegundos

Con 984us en p99, tu query mas lenta (1 de cada 100) sigue bajo un milisegundo. Esto significa que puedes agregar procesamiento adicional — re-ranking, personalizacion, logica de A/B testing — y aun retornar resultados en menos de 5ms totales. Ese es el presupuesto que Elasticsearch usa solo para la query de busqueda.

Throughput Concurrente

El throughput escala casi linealmente con workers concurrentes gracias a la arquitectura lock-free del IndexReader de Tantivy. Con 100 workers concurrentes, sostenemos 27,287 queries por segundo en un solo nodo.

WorkersTotal QueriesTiempo TotalQueries/seg
110020.9ms4,782
101,00051.1ms19,566
505,000211.9ms23,600
10010,000366.5ms27,287

Rendimiento del Query Parser

El query parser maneja terminos simples, queries de frase, negacion y expresiones booleanas complejas — todo bajo 103 microsegundos. Este es overhead pre-busqueda; se ejecuta antes de tocar el indice.

Tipo de QueryEjemploLatencia Media
Simplefracciones matematicas76.7us
Frase"tabla periodica" quimica74.0us
Negacionvolcanes -tectonica ciencias102.6us
Compleja"sistema solar" -pluton type:educational85.2us

Fusion de Ranking RRF

El paso de ranking hibrido agrega overhead despreciable. Incluso fusionando 1,000 resultados de ambos pipelines BM25 y semantico toma menos de 650 microsegundos.

Resultados a FusionarLatencia Media
106.0us
10058.3us
500317.6us
1,000647.6us

Eficiencia de Memoria

El indice usa aproximadamente 766 bytes por documento en RSS. Un corpus de 50K documentos agrega solo 36MB al footprint de memoria del proceso. Compara esto con Elasticsearch, donde un indice comparable tipicamente requiere 2–5KB por documento mas overhead del heap de la JVM.

MetricaValor
RSS antes de indexar56 MB
RSS despues de 50K docs93 MB
Delta36 MB
Bytes por documento~766 bytes

Analisis Profundo

Por que el Overhead de Locks es Cero

Un resultado sorprendente: la indexacion serial y batch rinden identico. La intuicion dice que el modo batch deberia evitar contencion de locks, pero en la practica el cuello de botella es la tokenizacion e I/O interna de Tantivy — no el write lock.

Tantivy usa un unico IndexWriter con un buffer interno de 50MB. Los documentos se tokenizan, analizan y escriben en segmentos en memoria antes de ser flusheados a disco. El write lock se mantiene solo durante la breve llamada a commit(), que dispara un flush de segmento. Durante la indexacion normal, el lock no tiene contencion.

Como Escalan las Lecturas Concurrentes

El IndexReader de Tantivy crea instancias ligeras de Searcher que comparten los mismos archivos de segmento memory-mapped via Arc. No se copia data. Cada thread de busqueda obtiene su propio Searcher, pero todos leen de las mismas paginas de memoria fisica. El page cache del OS hace el resto.

Esto es fundamentalmente diferente de los motores basados en JVM, donde cada query aloca objetos en el heap, incrementando la presion de GC bajo carga concurrente.

El Query Parser

Nuestro query parser soporta operadores booleanos, queries de frase con "comillas", queries por campo con sintaxis campo:valor, negacion con -termino, y matching con wildcards. Compila a un arbol Box<dyn Query> de Tantivy en menos de 100 microsegundos sin importar la complejidad.

El parser usa un enfoque de una sola pasada: tokeniza el input, clasifica cada token (limite de frase, prefijo de negacion, prefijo de campo, termino plano), y construye el arbol de consulta de abajo hacia arriba. Sin backtracking, sin ambiguedad. O(n) en longitud del input.

RRF: Lo Simple Vence a lo Complejo

Consideramos modelos de learned-to-rank para combinar scores de BM25 y semanticos. El problema: requieren datos de entrenamiento, agregan latencia de inferencia, y necesitan re-entrenamiento cuando el corpus cambia. RRF no tiene ninguno de estos problemas.

La formula es trivial: para cada documento que aparece en cualquier lista rankeada, sumar 1 / (k + rank) a traves de todas las listas. Ordenar por score. Listo. Con k=60 (la constante estandar), esto produce resultados competitivos con modelos entrenados en benchmarks estandar, a una fraccion de la complejidad.

Conclusiones Clave

Nota metodologica

Todos los benchmarks fueron generados usando un binario Rust dedicado (benchmark_report.rs) que crea documentos sinteticos que coinciden con nuestro schema de produccion, mide tiempo wall-clock con std::time::Instant, y computa percentiles de 100–10,000 muestras por medicion. El suite de benchmarks Criterion (core_benchmarks.rs) provee validacion estadistica con intervalos de confianza. El codigo fuente esta disponible para auditoria.

Necesitas este nivel de rendimiento
en tu stack de busqueda?

Auditamos y arquitecturamos sistemas Rust. Entrega en 48 horas. Hallazgos reales, no checklists.

Iniciar Auditoria audit@newcool.io