Búsquedas de texto por ranking de relevancia con R usando el algoritmo BM25
6/6/2026
Cuando tenemos datos que contienen mucho texto, usualmente necesitamos filtrar las observaciones dependiendo de si contienen o no uno o más términos.
Para filtrar entre un conjunto de datos de texto podemos usar distintos métodos. Hagamos un vector de texto para usar como ejemplo:
textos <- c(
"Construcción biblioteca y punto turístico, comuna de Salamanca",
"Mejoramiento de multicancha y construcción de techumbre población Arboleda, Vicuña",
"Construcción obras de iluminación fotovoltaicas sectores rurales y urbanos sin tendido eléctrico",
"Construcción plaza Las Tazas, comuna de Canela",
"Reposición sede club deportivo Victoria, localidad Los Choros, comuna La Higuera",
"Construcción pista de patinaje artístico, comuna Los Vilos",
"Construcción de arranques para el sector Saturno servicio sanitario rural de Gabriela Mistral",
"Adquisición de terreno para proyecto habitacional comité de vivienda Chapilca N°2",
"Mejoramiento servicio de agua potable ruta D-71, acceso poniente, Canela Baja",
"Conservación señaléticas direccionales comuna de Paihuano",
"Reposición sede social villa Los Mineros, Andacollo",
"Asistencia técnica para desarrollo de proyectos diversas localidades comuna de La Higuera",
"Construcción sistema de agua potable por acarreo sector Las Cruces, comuna de Punitaqui",
"Mejoramiento multicancha Maestranza, Los Arrayanes, Coquimbo",
"Fondo de fomento al equipamiento para la pesca artesanal, para los pescadores en caleta de Guayacán",
"Construcción salón multiuso agrupación El Lirio de los Valles",
"Mejoramiento multicancha Coyuntagua Sur, comuna de Illapel",
"Construcción redes de saneamiento sanitario e instalaciones domiciliarias loteo Higuera Baja, La Higuera, Coquimbo",
"Reposición de pavimentos aceras y calzadas, localidad de Chillepín, comuna de Salamanca",
"Mejoramiento de aceras población Serena Uno, La Serena",
"Construcción de sede comunitaria Ferro Unido Lambert",
"Asistencia técnica para proyectos de saneamiento sanitario varios sectores de la comuna de Vicuña",
"Iluminación cancha de fútbol, El Trapiche, comuna La Higuera",
"Mejoramiento plaza de Ramadilla, comuna de Combarbalá",
"Compra de terreno para cementerio municipal comuna de Coquimbo",
"Construcción de veredas y luminarias solares localidades Samo Alto y La Aguada, comuna de Río Hurtado",
"Mejoramiento sede social Villa Aurora, Las Compañías, comuna de La Serena",
"Elaboración de estrategia energética local para la comuna de La Higuera",
"Ampliación sede Quebrada del Jardín, La Serena",
"Construcción sede comunitaria Arcos de Pinamar, La Serena"
)
Búsqueda de texto con {stringr}
La función str_detect() del paquete {stringr} permite detectar los elementos donde aparece el texto:
library(stringr)
str_detect(textos,
"cancha")
[1] FALSE TRUE FALSE FALSE FALSE FALSE FALSE FALSE FALSE FALSE FALSE FALSE
[13] FALSE TRUE FALSE FALSE TRUE FALSE FALSE FALSE FALSE FALSE TRUE FALSE
[25] FALSE FALSE FALSE FALSE FALSE FALSE
str_detect() retorna verdaderos y falsos, pero si metemos los datos en un dataframe y la usamos en un filter() servirá para filtrar las observaciones.
Metamos el vector en una tabla de datos:
library(dplyr)
# crear tabla
datos <- tibble(textos)
Ahora podemos usar str_detect() para filtrar las observaciones que contienen una palabra o texto específicos:
datos |>
filter(
str_detect(textos, "agua")
)
# A tibble: 3 × 1
textos
<chr>
1 Mejoramiento servicio de agua potable ruta D-71, acceso poniente, Canela Baja
2 Construcción sistema de agua potable por acarreo sector Las Cruces, comuna de…
3 Mejoramiento multicancha Coyuntagua Sur, comuna de Illapel
Pero ¿qué pasa si buscamos un texto que no está exactamente entre los datos, o si hay varios textos similares pero no idénticos?
datos |>
filter(
str_detect(textos, "agua potable rural")
)
# A tibble: 0 × 1
# ℹ 1 variable: textos <chr>
No se encuentra nada, a pesar de existir textos que contienen esas palabras. Esto pasa porque str_detect() busca coincidencias exactas en el texto.
Búsqueda de texto con regex
Una alternativa es usar regex, expresiones regulares para búsquedas de texto. Son símbolos especiales que podemos usar al coincidir texto para configurar la forma en que se aplica. Por ejemplo, el término de búsqueda eres (lindo|linda) coincide tanto con "eres lindo" como con "eres linda", o podría ser eres lind(o|a|e) para definir que la variedad está en una sola letra, e incluso podría ser eres lind., donde el punto representa cualquier caracter posible (así que incluso coincidiría con "eres lindx").
Volviendo al caso del ejemplo anterior, podríamos buscar las palabras "agua potable rural" separadas por el operador | para que se encuentre textos que contengan cualquiera de las tres palabras:
datos |>
filter(
str_detect(textos, "agua|potable|rural")
)
# A tibble: 5 × 1
textos
<chr>
1 Construcción obras de iluminación fotovoltaicas sectores rurales y urbanos si…
2 Construcción de arranques para el sector Saturno servicio sanitario rural de …
3 Mejoramiento servicio de agua potable ruta D-71, acceso poniente, Canela Baja
4 Construcción sistema de agua potable por acarreo sector Las Cruces, comuna de…
5 Mejoramiento multicancha Coyuntagua Sur, comuna de Illapel
Ahora sí se encuentran textos coincidentes!
Búsqueda de texto por relevancia
Si necesitamos buscar o detectar texto y obtener las mejores coincidencias posibles, a pesar de que sean inexactas, podemos usar el algoritmo de búsqueda Okapi BM25, usado ampliamente por servicios de búsqueda de internet.
install.packages("rbm25")
El paquete {rbm25} de R entrega la función bm25_score(), que a diferencia de una búsqueda por coincidencia de texto, entrega un ranking de resultados, el cual puedes usar para ordenar los resultados posibles según su relevancia al texto de búsqueda.
library(rbm25)
datos |>
mutate(
puntaje = bm25_score(
textos,
query = "mejoramiento y construcción de sedes comunitarias",
lang = "es"
)
) |>
arrange(desc(puntaje)) |>
select(puntaje, textos) |>
head(n = 6)
# A tibble: 6 × 2
puntaje textos
<dbl> <chr>
1 5.34 Construcción de sede comunitaria Ferro Unido Lambert
2 5.34 Construcción sede comunitaria Arcos de Pinamar, La Serena
3 2.85 Mejoramiento sede social Villa Aurora, Las Compañías, comuna de La Se…
4 2.35 Mejoramiento de multicancha y construcción de techumbre población Arb…
5 1.78 Ampliación sede Quebrada del Jardín, La Serena
6 1.67 Reposición sede social villa Los Mineros, Andacollo
Obtenemos el puntaje en una columna, que luego usamos para ordenar los resultados por relevancia.
Pero antes de hacer una búsqueda de texto en serio, se recomienda realizar una limpieza del texto. Para limpiar texto, como mínimo necesitamos:
- Convertir a minúsculas con
str_to_lower() - Eliminar toda la puntuación usando regex y
str_remove_all() - Eliminar las stopwords o palabras vacías, que podemos obtener del paquete
{stopwords}
Primero creamos el texto regex para eliminar stopwords:
# install.packages("stopwords")
stopwords <- stopwords::stopwords(language = "es")
stopwords <- paste(stopwords, collapse = "|")
stopwords <- paste0("\\b(", stopwords , ")\\b")
Explicación de la limpieza de stopwords
stopwords() que luego eliminaremos con str_remove(). Uniremos las palabras con paste() para que queden|pegadas|así, separadas con el símbolo | que es el operador lógico o, para decir en regex que queremos que se elimine la palabra 1 o la 2 o la 3; es decir, eliminar todas las que se encuentren. Finalmente, envolvemos las palabras en el regex \\b, que representa palabras completas (para que por borrar la palabra vacía la no se eliminen las letras la de la palabra lavaloza)
Luego procedemos con la limpieza
datos_limpios <- datos |>
mutate(
texto_limpio = textos |>
str_to_lower() |> # minúsculas
str_remove_all("[[:punct:]]") |> # puntuación
str_remove_all(stopwords) |> # palabras vacías
str_squish() # eliminar espacios sobrantes
)
datos_limpios |>
select(texto_limpio)
# A tibble: 30 × 1
texto_limpio
<chr>
1 construcción biblioteca punto turístico comuna salamanca
2 mejoramiento multicancha construcción techumbre población arboleda vicuña
3 construcción obras iluminación fotovoltaicas sectores rurales urbanos tendid…
4 construcción plaza tazas comuna canela
5 reposición sede club deportivo victoria localidad choros comuna higuera
6 construcción pista patinaje artístico comuna vilos
7 construcción arranques sector saturno servicio sanitario rural gabriela mist…
8 adquisición terreno proyecto habitacional comité vivienda chapilca n°2
9 mejoramiento servicio agua potable ruta d71 acceso poniente canela baja
10 conservación señaléticas direccionales comuna paihuano
# ℹ 20 more rows
Ahora probemos nuevamente la búsqueda, pero esta vez sobre el texto limpio:
# búsqueda de texto
datos_limpios |>
mutate(
puntaje = bm25_score(
texto_limpio,
query = "mejoramiento y construcción de sedes comunitarias",
lang = "es"
)
) |>
arrange(desc(puntaje)) |>
select(puntaje, texto_limpio) |>
head(n = 6)
# A tibble: 6 × 2
puntaje texto_limpio
<dbl> <chr>
1 5.33 construcción sede comunitaria ferro unido lambert
2 5.33 construcción sede comunitaria arcos pinamar serena
3 2.84 mejoramiento sede social villa aurora compañías comuna serena
4 2.35 mejoramiento multicancha construcción techumbre población arboleda vi…
5 1.78 ampliación sede quebrada jardín serena
6 1.67 reposición sede social villa mineros andacollo
Los resultados casi no cambian, pero cuando realicemos búsquedas entre miles de textos, o con textos mucho más extensos, puede marcar la diferencia.
Resumen
library(dplyr)
# buscar por coincidencia exacta
datos |>
filter(
stringr::str_detect(textos, "agua|potable|rural")
) |>
head(4)
# buscar con algoritmo de relevancia
datos |>
mutate(
puntaje = rbm25::bm25_score(
textos, "agua potable rural")
) |>
arrange(-puntaje) |>
head(4)
Bonus
Benchmarks
Tenemos muchísimas funciones en R para trabajar textos, y yo pensaba que las de R base iban a ser más rápidas/eficientes, pero hice un benchmark y me llevé una sorpresa:
library(dplyr)
library(stringr)
library(rbm25)
stopwords <- stopwords::stopwords(language = "es")
# benchmark entre dos limpiezas de texto
bench::mark(
check = FALSE,
iterations = 100,
# limpieza de texto con R base
"R base" = textos |>
tolower() |>
gsub(pattern = "[[:punct:]]", replacement = "") |>
gsub(
pattern = paste0("\\b(", paste(stopwords, collapse = "|"), ") *\\b"),
replacement = ""
) |>
trimws(),
# limpieza de texto con {stringr}
"stringr" = textos |>
str_to_lower() |>
str_remove_all("[[:punct:]]") |>
str_remove_all(paste0("\\b(", paste(stopwords, collapse = "|"), ")\\b")) |>
str_squish()
)
# A tibble: 2 × 6
expression min median `itr/sec` mem_alloc `gc/sec`
<bch:expr> <bch:tm> <bch:tm> <dbl> <bch:byt> <dbl>
1 R base 50.6ms 53.3ms 18.5 34.27KB 0
2 stringr 641.7µs 653.8µs 1524. 5.12KB 0
Las funciones de {stringr} son muchísimo más rápidas que las de R base en este caso!
También pensaba que buscar con BM25 iba a ser mucho más lento que con str_detect(), y a su vez que ésta iba a ser más lenta que grep(), la función de R base, así que las puse a prueba:
bench::mark(
check = FALSE,
iterations = 100,
"grep" = grep("plaza", textos),
"str_detect" = str_detect(textos, "plaza"),
"bm25" = bm25_score(textos, "plaza")
)
# A tibble: 3 × 6
expression min median `itr/sec` mem_alloc `gc/sec`
<bch:expr> <bch:tm> <bch:tm> <dbl> <bch:byt> <dbl>
1 grep 23.9µs 24.44µs 40414. 10.16KB 0
2 str_detect 16.89µs 17.41µs 56754. 216B 0
3 bm25 1.15ms 1.17ms 833. 5.78KB 8.41
Vemos que {stringr} es mucho más rápido que R base, y que efectivamente buscar con BM25 es lento, pero tampoco tan lento.
En fin, hice todo esto porque actualicé el buscador de mi blog, que en sí mismo es una aplicación Shiny, para que ahora busque con BM25 y así entregue mejores resultados, y lo mejor: ordenados por relevancia. Puedes ver su código en este repositorio.
Publicaciones sobre texto