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).
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:
- BM25 de Tantivy — relevancia clasica por frecuencia de terminos, rapida y determinista
- Similaridad semantica de Qdrant — embeddings vectoriales para matching basado en significado
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 Corpus | Metodo | Tiempo | 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
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.
| Corpus | p50 | p95 | p99 | Media |
|---|---|---|---|---|
| 1K | 119us | 296us | 335us | 136us |
| 10K | 131us | 294us | 537us | 160us |
| 50K | 234us | 712us | 984us | 290us |
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.
| Workers | Total Queries | Tiempo 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 |
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 Query | Ejemplo | Latencia Media |
|---|---|---|
| Simple | fracciones matematicas | 76.7us |
| Frase | "tabla periodica" quimica | 74.0us |
| Negacion | volcanes -tectonica ciencias | 102.6us |
| Compleja | "sistema solar" -pluton type:educational | 85.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 Fusionar | Latencia Media |
|---|---|
| 10 | 6.0us |
| 100 | 58.3us |
| 500 | 317.6us |
| 1,000 | 647.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.
| Metrica | Valor |
|---|---|
| RSS antes de indexar | 56 MB |
| RSS despues de 50K docs | 93 MB |
| Delta | 36 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
- Rust + Tantivy elimina el impuesto JVM. Sin pausas de GC, sin bloat de heap, sin tiempo de warmup. Tu p99 es tu p99 — no p99-menos-pausas-de-GC.
- La latencia p99 sub-milisegundo es alcanzable. Con 50K documentos, nuestra peor query (1 de cada 100) retorna en 984 microsegundos. Esto deja presupuesto para logica de aplicacion.
- La concurrencia escala sin esfuerzo. La arquitectura mmap + Arc de Tantivy significa que no necesitas pensar en read locks. Solo instancia mas Searchers.
- RRF es el default correcto para busqueda hibrida. Sin entrenamiento, sin tuning, complejidad O(n), latencia sub-milisegundo. Empieza aqui; recurre a LTR solo si lo necesitas.
- 766 bytes por documento. Tu laptop puede indexar un millon de documentos y aun tener RAM de sobra.
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.