Saltar al contenido
Todas las entradas

Búsqueda de texto completo en Room: añadir búsqueda instantánea a una app Android local-first en 2026

Una guía práctica del soporte FTS4 de Room en Android — construir una tabla virtual de búsqueda, mantenerla sincronizada con triggers, y por qué FTS5 exige una migración manual.

MFKAPPS 6 min de lectura

La búsqueda es la función que nadie nota hasta que es lenta. Escribe “yog” en una despensa de trescientos artículos y espera que la lista se filtre mientras escribes — si tarda incluso 200 ms en responder, la app se siente rota. La mayoría de las apps basadas en Room manejan esto con una cláusula LIKE '%consulta%', y funciona bien en tablas pequeñas. Deja de funcionar en cuanto la tabla crece, la consulta tiene más de una palabra, o quieres tolerancia a errores tipográficos. SQLite tiene una respuesta real a esto desde siempre: búsqueda de texto completo, expuesta en Room como la anotación @Fts4. Así es como funciona realmente, dónde se rompe, y por qué FTS5 — la versión que la mayoría de los tutoriales asumen que usas — no es algo que Room te dé gratis.

Por qué LIKE no escala

SELECT * FROM pantry_items WHERE name LIKE '%yogur%' no puede usar un índice. SQLite tiene que escanear cada fila y ejecutar una coincidencia de subcadena en cada una. En unos pocos cientos de filas, es invisible. En unos pocos miles — algo que una app de despensa con escaneo de códigos de barras e historial de recibos alcanza más rápido de lo que uno pensaría — se convierte en un tartamudeo visible en cada pulsación de tecla, especialmente si la consulta también tiene que hacer OR entre varias columnas (nombre, marca, categoría).

La búsqueda de texto completo invierte esto. En lugar de escanear filas, SQLite construye un índice invertido al momento de escribir: cada palabra se asocia a las filas que la contienen. Una búsqueda se convierte en una consulta de índice, no en un escaneo, así que el rendimiento se mantiene estable a medida que la tabla crece.

Lo que Room realmente te da: FTS4, no FTS5

Esta es la parte que confunde a la gente, porque la mayoría del contenido sobre FTS en la web asume el módulo FTS5 más nuevo de SQLite. La anotación @Fts4 de Room conecta una tabla virtual FTS4 — no tiene un equivalente @Fts5. FTS4 y FTS5 difieren lo suficiente como para que esto importe: FTS5 tiene una sintaxis de consulta más sensata, un ranking bm25() incorporado, y un mejor manejo de consultas por prefijo, nada de lo cual Room te da automáticamente con FTS4.

Si necesitas FTS5, aún puedes conseguirlo — simplemente lo haces a mano, creando tú mismo la tabla virtual dentro de una Migration con SQL crudo (CREATE VIRTUAL TABLE ... USING fts5(...)) en lugar de una entidad anotada, y mapeándola con una @DatabaseView simple o una consulta cruda para las lecturas. Para la mayoría de los casos de búsqueda local — nombres de artículos de despensa, nombres de suscripciones, títulos de notas — FTS4 es más que suficiente, y no te cuesta nada más que una anotación. Empieza ahí; recurre a la ruta manual de FTS5 solo si realmente necesitas consultas de frase o ranking incorporado.

Conectar una tabla FTS4 a Room

Una tabla FTS necesita una entidad de contenido acompañante — la tabla normal que ya consultas para todo lo demás — más una tabla virtual que indexa las columnas buscables:

@Entity(tableName = "pantry_items")
data class PantryItem(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val name: String,
    val brand: String,
    val category: String,
)

@Fts4(contentEntity = PantryItem::class)
@Entity(tableName = "pantry_items_fts")
data class PantryItemFts(
    val name: String,
    val brand: String,
    val category: String,
)

contentEntity le dice a Room que trate pantry_items como la fuente de verdad y que almacene solo el índice en la tabla FTS, no una copia de cada fila. La consulta del DAO se ve casi idéntica a una normal, excepto que hace join contra el rowid de la tabla FTS:

@Query("""
    SELECT pantry_items.* FROM pantry_items
    JOIN pantry_items_fts ON pantry_items.id = pantry_items_fts.rowid
    WHERE pantry_items_fts MATCH :query
""")
fun search(query: String): Flow<List<PantryItem>>

MATCH es el operador de FTS — es lo que convierte la consulta en una búsqueda por índice en lugar de un escaneo. Pasar "yog*" en lugar de "yog" te da coincidencia por prefijo, que es lo que quieres para búsqueda mientras se escribe.

Mantener el índice sincronizado

Con contentEntity, Room genera los triggers que mantienen pantry_items_fts sincronizado con pantry_items en inserciones, actualizaciones y eliminaciones — no los escribes a mano. Lo único a vigilar: esta sincronización solo cubre las escrituras que pasan por los métodos insert/update/delete generados por Room. Un UPDATE SQL crudo ejecutado fuera de la capa DAO de Room, o una importación masiva hecha con execSQL, se salta el mapeo de entidad de contenido respaldado por triggers de formas sutiles y fáciles de hacer mal. Enruta las modificaciones de la despensa — incluida la ruta de inserción por escaneo de código de barras — a través de los mismos métodos DAO que la validación de esquema de Room ya tiene en cuenta, en lugar de un atajo SQL crudo separado para importaciones “rápidas”.

Ranking: la limitación real de FTS4

FTS4 te da coincidencias correctas pero ningún ranking de relevancia más allá del orden de coincidencia. Si alguien busca “leche” y coinciden tanto “Leche Entera” como “Barra de Chocolate con Leche”, FTS4 no te dirá cuál probablemente quería el usuario — obtienes filas en el orden de la tabla, no en orden de relevancia. Para unos pocos cientos de artículos de despensa, esto raramente importa en la práctica; la lista es lo bastante corta como para escanearla visualmente. Si empieza a importar — un catálogo más grande, o búsqueda en un conjunto de campos más amplio — el arreglo sin saltar a FTS5 es un simple reordenamiento del lado del cliente: extrae las coincidencias, luego ordénalas según si la consulta coincide con el inicio del nombre antes que cualquier otra cosa. Son unas pocas líneas de Kotlin, no una migración de base de datos, y corrige el caso que realmente molesta a los usuarios: coincidencias exactas y de prefijo enterradas debajo de coincidencias parciales no relacionadas.

La conclusión

El soporte FTS4 de Room convierte un escaneo lineal en una consulta de índice por el precio de una anotación y una consulta DAO ligeramente diferente — sin servidor, sin SDK de búsqueda de terceros, sin ida y vuelta de red para algo que debería sentirse instantáneo. Es el valor por defecto correcto para la búsqueda dentro de cualquier app Android local-first. Las dos cosas que vale la pena recordar: Room habla FTS4, no FTS5, así que no diseñes en torno a funciones de ranking que solo existen en el módulo más nuevo, y cada ruta de escritura que se supone debe ser buscable tiene que pasar por la capa DAO de Room, o el índice se desincroniza silenciosamente de la tabla que se supone debe describir. Haz esas dos cosas bien y la búsqueda deja de ser una función de la que preocuparse.

Si quieres ver este patrón en una app publicada, Stocky lo usa para buscar en una despensa que puede crecer a cientos de artículos — escaneos de códigos de barras, importaciones de recibos y entradas manuales que caen todos en la misma tabla buscable.