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.

filas 1 · el zorro rápido 2 · zorro pardo rápido 3 · una liebre parda tokenizar diccionario de términos rápido → t1 zorro → t2 pardo → t3 liebre → t4 término guardado una vez posting lists t1 → 1, 2 t2 → 1, 2 t3 → 2, 3 t4 → 3 + posiciones por fila
Las filas se convierten en términos; el diccionario guarda cada término una sola vez como un id corto; las posting lists apuntan de cada id de término a las filas que lo contienen.

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.

rango de claves de texto completo — uno por índice Diccionario término (guardado una vez, preserva el orden) → id de término de 4 bytes Postings (id de término, id de fila) → posiciones por campo, codificadas por deltas Estadísticas longitud del doc · longitud media · frecuencia documental (df) por término esquema de catálogo v8 · disjunto de los índices normales, así los escaneos nunca se cruzan
Tres partes en un mismo rango de claves: un diccionario para no repetir términos, postings ordenados por término y los recuentos 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.

Sin — prefijo repetido en cada celda [fts·t1] · fila 7 → pos [fts·t1] · fila 12 → pos [fts·t1] · fila 40 → pos [fts·t1] guardado 3× — ~9 bytes cada vez Con — prefijo guardado una vez por hoja prefijo de hoja [fts·t1] fila 7 → pos fila 12 → pos fila 40 → pos prefijo una vez — las celdas solo guardan el id de fila
La compresión de prefijos es lo que puso el índice por debajo de FTS5: pura codificación de almacenamiento que deja intactos el modelo lógico, las escrituras incrementales y el versionado, y que ayuda a todos los índices, no solo al de texto completo.

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érminosindex
  • booleanosindex AND rust, index OR search, index NOT stale
  • frase"inverted index"
  • prefijoindex*
  • proximidadNEAR(index rust, 5)
  • por columnatitle: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.