Nativa, no un segundo índice
Lo habitual para añadir búsqueda es levantar un índice aparte — el FTS5 de SQLite, o un motor de búsqueda junto a tu base de datos. Eso obliga a mantener sincronizados dos almacenes: cada escritura tiene que actualizar los dos, y un fallo entre medias los deja en desacuerdo. Peor aún: con datos privados pasas a guardar el contenido dos veces, y la segunda copia suele ser un índice en claro fuera de tu almacén cifrado.
La búsqueda nativa elimina toda esa clase de problemas. El índice invertido se escribe en la misma transacción que la fila, con el único fdatasync que cada commit de Arkeion ya paga, así que nunca puede divergir de los datos. Hereda cifrado, copias de seguridad, viajes en el tiempo y la cadena de auditoría sin coste adicional: un único fichero cifrado, sin una segunda copia de tu contenido.
El índice invertido
Por debajo, la búsqueda de texto completo es un índice invertido. Un índice normal asocia una fila a sus valores; un índice invertido asocia cada término a la lista de documentos que lo contienen (un “documento” es una fila; cada columna indexada se tokeniza de forma independiente). Entra texto, se descompone en términos y cada término apunta a las filas donde aparece.
El tokenizador
El tokenizador convierte el texto en términos. Es determinista y sin modelos (no es un tokenizador de LLM): la misma fila produce siempre los mismos términos, que es lo que hace auditable el índice. No tiene dependencias y no contiene código unsafe: todo sale de la biblioteca estándar.
Hoy se incluyen dos:
unicode(por defecto): un término es una secuencia maximal de caracteres alfanuméricos, en minúsculas, con plegado de diacríticos escrito a mano (café → cafe,ñ → n,ß → ss,œ → oe, …) en los rangos europeos habituales.ascii: solo alfanuméricos ASCII, en minúsculas, sin plegado; más rápido cuando no lo necesitas.
El tokenizador es un trait, así que más adelante pueden añadirse stemmers específicos de cada idioma o un tokenizador que entienda direcciones (que parta bob@example.com en bob, example.com y la dirección entera) sin cambiar el que viene por defecto. Cada token registra sus desplazamientos de byte sobre el texto original, de modo que snippet() y highlight() pueden subrayar la coincidencia exacta sin guardar desplazamientos en el índice.
Cómo se almacena
Un índice FTS es un tipo especial de índice secundario: el mismo b-tree versionado y el mismo mantenimiento en cada insert, update y delete, pero con un grano más fino, una entrada por token en lugar de una por valor de columna. Para que no interfiera con los índices ordinarios, los datos de texto completo viven en su propio rango de claves, dividido en un diccionario, las postings y las estadísticas que BM25 necesita.
Las piezas:
- Diccionario de términos. Cada término se almacena una sola vez y se asocia a un id de 4 bytes. Las postings se indexan por ese id, así que la palabra en sí nunca se repite en una posting. Una búsqueda por prefijo
term*se convierte en un recorrido por rango del diccionario que devuelve el conjunto de ids coincidentes. - Postings. Una celda por
(term, row), con clave(term id, row id). El valor guarda, para cada campo, las posiciones del token como delta-varints (field, count, pos0, Δpos…). Un recorrido por prefijo sobre un id de término devuelve todos los(row, positions)de ese término. Mantener una celda por(term, row)— en lugar de un único blob empaquetado por término — es lo que hace que las actualizaciones y los borrados sean operaciones limpias sobre una sola celda, que es justo lo que quieren el copy-on-write y el aislamiento por snapshots. - Estadísticas. BM25 necesita tres recuentos: la longitud de cada documento, la longitud media del índice y la frecuencia documental (
df) de cada término. Se mantienen de forma incremental a medida que cambian las filas, así que nunca hay que recalcularlas en tiempo de consulta.
La definición del índice (nombre, columnas, tokenizador) se guarda dentro del esquema de la tabla, de modo que el mantenimiento ve el índice sin una consulta extra en cada escritura.
Compresión de prefijos
La palanca que hizo pequeño el índice es la compresión de prefijos del b-tree en el nivel de las hojas. Todas las celdas de posting de un mismo término empiezan por el mismo prefijo de clave — el tag de texto completo más el id del término, unos nueve bytes. En lugar de repetir ese prefijo en cada celda, la hoja lo guarda una vez por página y cada celda conserva solo lo que difiere (el id de fila). Los ids de fila y de término se almacenan con longitud variable y preservando el orden, en vez de en campos fijos de 8 y 4 bytes.
Ranking con BM25
MATCH encuentra las filas; BM25 las ordena. Es la puntuación de relevancia estándar, y equilibra tres ideas: un término que aparece más veces en una fila cuenta más (frecuencia del término), un término raro en el corpus cuenta más (frecuencia documental inversa) y una coincidencia en un campo corto cuenta más que la misma coincidencia en uno muy largo (normalización por longitud). Las tres entradas — frecuencia del término, longitud del documento, longitud media y df — salen directamente del valor de la posting y de las claves de estadísticas.
SELECT id, snippet(body, 'index'), bm25(body, 'index') AS rank
FROM docs
WHERE body MATCH 'index'
ORDER BY rank DESC
LIMIT 10;
Cuando una consulta es un MATCH ordenado por bm25(...) con un LIMIT, el ranking se calcula directamente desde el índice y solo se recupera y se vuelve a puntuar con exactitud una lista corta con las mejores filas, así que la mayoría de las filas coincidentes nunca se leen ni se vuelven a tokenizar. La misma fórmula BM25 respalda tanto esta vía rápida como la función bm25() por fila, de modo que no pueden separarse. snippet() y highlight() vuelven a tokenizar solo las filas que estás mostrando para colocar los marcadores, usando los desplazamientos de byte que registró el tokenizador.
El lenguaje de consulta
La cadena a la derecha de MATCH tiene su propia pequeña gramática, cercana a la de FTS5:
- términos —
index - booleanos —
index AND rust,index OR search,index NOT stale - frase —
"inverted index" - prefijo —
index* - proximidad —
NEAR(index rust, 5) - por columna —
title:index
Viajes en el tiempo, y sobrevivir al vacuum
Como el índice es un ciudadano más del historial copy-on-write, salen dos cosas útiles gratis. MATCH … AS OF VERSION n busca en el índice tal y como estaba en una versión anterior: búsqueda de texto completo sobre el pasado, no solo sobre los datos. Y vacuum, que recupera el espacio de las versiones superadas, mantiene intacto el índice actual sin ningún tratamiento especial, porque no hay un almacén aparte que reconstruir.
Rendimiento
Medido sobre 50k pasajes de MS MARCO, embebido y en proceso, frente al FTS5 de SQLite en la misma máquina:
| formato anterior | Arkeion | SQLite FTS5 | |
|---|---|---|---|
| Tamaño del índice | 146 MB | 26.2 MB | 27.2 MB |
| Construcción | 63 s | 11 s | 0.6 s |
| Consulta de término frecuente | 25.6 ms | 2.5 ms | 1.1 ms |
| Consulta de prefijo / frase | 9.3 / 9.1 ms | 1.4 / 2.6 ms | 0.5 / 0.6 ms |
Léelo con honestidad. El índice acabó siendo más pequeño que el de FTS5 (26.2 frente a 27.2 MB): un almacén versionado, cifrado y capaz de viajar en el tiempo ganando en compacidad a un motor especializado en C. Donde FTS5 sigue ganando es en velocidad de construcción y latencia de consulta: sus segmentos empaquetados se construyen y se recorren más rápido que una celda de b-tree por posting. El intercambio compra algo que FTS5 no puede ofrecer — búsqueda demostrable, cifrada y direccionable en el pasado — sin nada del riesgo de sincronización de dos almacenes.