Rápido y más inteligente: Potencia tus búsquedas con NLP, ML y Annoy

Niriana Blasco
12 min readSep 23, 2023

--

En un post previo, exploramos el potente mundo de Elasticsearch como motor de búsqueda. Ahora, estamos listos para llevar tus habilidades de búsqueda un paso más allá. En este artículo, nos sumergiremos en el emocionante terreno de búsquedas más rápidas y más inteligentes. Las tecnologías de Procesamiento de Lenguaje Natural (NLP), K-Nearest Neighbors (KNN) y Annoy son las estrellas de éste post, y están transformando la forma en que buscamos información. Más adelante, en otro post, evolucionaremos nuestras búsquedas con el uso de bases de datos vectoriales y un potente framework llamado LangChain.

¿El objetivo? Proporcionarte todas las herramientas que necesitas para que tus aplicaciones y proyectos de desarrollo de software sean aún más eficientes y precisos. Si eres un desarrollador que busca resultados tangibles y líneas de código prácticas, estás en el lugar correcto. Sin excesos teóricos, vamos directo a la acción para que puedas comenzar a utilizar estas tecnologías en tus proyectos. ¡Prepárate para una experiencia de búsqueda más inteligente y eficaz!

Tecnologías Claves

Procesamiento de Lenguaje Natural (NLP):

Para que la búsqueda inteligente sea posible, es de vital importancia comprender el NLP, ya que es la piedra angular que habilita a las máquinas para entender el lenguaje humano. Imagínalo como la habilidad de las computadoras para comprender, interpretar y responder al lenguaje humano de manera análoga a cómo lo haría una persona. Esto se traduce en búsquedas más precisas y resultados que se ajustan a lo que realmente estás buscando. El NLP aborda tareas específicas que permiten esta transformación del texto al lenguaje comprensible por las máquinas, incluyendo la tokenización, lematización, reconocimiento de entidades, entre otras. Algunas de las herramientas más populares en este campo incluyen:

K-Nearest Neighbors (KNN):

KNN, o Vecinos Más Cercanos, es como el GPS de la búsqueda inteligente. Imagina que cada elemento de tu conjunto de datos es un punto en el mapa, y KNN te dice cuáles son los puntos más cercanos a tu ubicación actual. En el contexto de la búsqueda, esto se traduce en encontrar elementos similares a lo que estás buscando. Es una técnica poderosa para buscar contenido relacionado y proporcionar recomendaciones precisas. E éste artículo usaremos el algoritmo KNN de scikit-learn.

Annoy:

Annoy será la pieza clave que permitirá darle velocidad a nuestras búsquedas. Esta biblioteca especializada tiene una misión específica: acelerar la búsqueda de información similar. Para entenderlo mejor, piensa en tu biblioteca personal, llena de libros de diversos géneros y autores. Cuando buscas libros similares, Annoy actúa como un asistente eficiente que te ayuda a encontrarlos en un abrir y cerrar de ojos. Utiliza una técnica inteligente conocida como búsqueda de “vecinos más cercanos aproximados” para identificar elementos similares en grandes conjuntos de datos de manera extremadamente rápida. Esta capacidad de Annoy es una ventaja crucial en la búsqueda en línea moderna, donde la velocidad es esencial para ofrecer resultados precisos de manera instantánea. Imagina tener la capacidad de encontrar documentos, productos o contenido relacionado de manera casi instantánea: eso es lo que Annoy hace posible en la búsqueda inteligente.

Bueno creo que es suficiente de tanta teoría, vamos manos a la obra!

Fuentes de datos

Imaginemos que tenemos acceso a un extenso repositorio de información sobre libros, que incluye datos sobre títulos, autores, géneros, sinopsis y más. Este conjunto de datos se asemeja a lo que podrías encontrar en una biblioteca virtual o una plataforma de comercio electrónico de libros. Para nuestras pruebas usaremos como fuente de datos el conjunto de datos en formato csv proveniente de source. He seleccionado ésta fuente de datos porque tiene mas de 50k de libros y nos ayudará a poder contrastar los resultados cuando usemos KNN versus Annoy. Exploremos los datos:

books.csv

Contiene muchas columnas con datos relevantes sobre libros, sin embargo seleccionaremos las que necesitaremos para nuestro motor de búsqueda:

import pandas as pd

df = pd.read_csv('../data/books.csv')

features = [
'title', 'author', 'description', 'genres', 'language', 'isbn', 'publishDate'
]

df[features].info()
Usamos el método info de dataframe de pandas
df[features].head(10)
Primeras 10 filas del conjunto de datos

Revisemos cuál es el género o tema de libro más destacado en nuestro conjunto de datos:

El género Fiction tiene un 64% de libros en el dataset.
EL 81% de los libros están en Inglés.

Preprocesamiento con NLP

Ahora que estamos familiarizados con nuestra fuente de datos, es hora de preparar el terreno para aplicar nuestras tecnologías clave, empezando por el Procesamiento de Lenguaje Natural (NLP). El preprocesamiento de datos con NLP es como afinar un instrumento musical antes de tocar una sinfonía; nos asegura que nuestros datos estén listos para ser interpretados de manera eficiente y precisa.

El primer paso es seleccionar qué datos queremos usar como campos de búsqueda y verificamos que éstas columnas no tengan valores nulos:

search_fields = ['title', 'description', 'author', 'genres']
df[search_fields].isna().sum()

En nuestros datos tenemos la columna description con valores nulos, podemos reemplazar estos valores faltantes con una cadena vacía y procedemos a unir todos estos datos en una única columna llamada “text”:

df['description'] = df['description'].fillna(' ')
df['text'] = df[search_fields].apply(lambda x: ' '.join(x.values), axis=1)
df[features + ['text']]

El siguiente paso en el preprocesamiento con NLP es la tokenización. Imagina que nuestros datos son un largo párrafo de texto, y queremos dividirlo en palabras o unidades significativas. Cada una de estas unidades se llama “token”. Por ejemplo, la frase “El gato corre velozmente” se tokenizaría en [“El”, “gato”, “corre”, “velozmente”].

También necesitaremos eliminar stopwords. Los stopwords son palabras comunes y frecuentes en un idioma, como “el”, “de”, “en”, que a menudo no aportan información significativa en el análisis de texto. Durante el preprocesamiento, eliminamos estos stopwords para reducir el ruido y centrarnos en las palabras clave que realmente importan.

Para poder eliminar los stopwords necesitamos tener una lista con todos ellos para cada idioma que tenga nuestro texto, en este caso según nuestro análisis vimos que el 85% de los libros están en idioma English, Arabic y Spanish, por lo que usaremos nltk para obtener todas estas stopwords.

import nltk
nltk.download('punkt')
nltk.download('stopwords')

from nltk.corpus import stopwords
stop_words = []

for lang in ['english', 'spanish', 'arabic', 'french']:
stop_words.extend(stopwords.words(lang))

Ahora procedemos a limpiar nuestros datos, para ello he creado un objeto que aplica varias técnicas de limpieza recomendadas para campos de búsqueda: eliminar acentos, eliminar código html, eliminar stopwords, etc.

import re
import numpy as np
from bs4 import BeautifulSoup
import unicodedata



class TextProcessing:
def __init__(self, stop_words=None, exclude_numbers=True):
self.stop_words = stop_words
self.text = None
self.exclude_numbers = exclude_numbers

def clean(self, text):
self.text = text
text = text.replace('-', ' ')
text = text.replace(',', ' ')
text = text.strip()
text = self.strip_punctuation(text)
texts = text.split(' ')
texts_new = []
for word in texts:
word = word.lower()
word = self.strip_accent(word)
word = self.strip_html(word)
if word not in [None, np.nan]:
texts_new.append(word)
text_new = (' '.join(texts_new)).strip() if texts_new else np.nan
return text_new

def strip_accent(self, text):
try:
text = unicode(text, 'utf-8')
except NameError:
pass

text = unicodedata.normalize('NFD', text) \
.encode('ascii', 'ignore') \
.decode("utf-8")

return str(text)

def strip_html(self, text):
soup = BeautifulSoup(text, "html.parser")
text = soup.get_text(strip=True)
return text

def strip_punctuation(self, text):
return re.sub(r'[^\w\s]', '', text)

Ahora aplicamos este procesador de texto a nuestra columna llamada “text” en el conjunto de datos

df['text'] = df['text'].apply(lambda x: TextProcessing(stop_words=stop_words).clean(x))

Una vez depurado nuestro texto, es el momento de entrar en la fase de transformación. Es posible que te preguntes por qué realizar todo este proceso cuando existen transformadores de alto rendimiento como el Universal Sentence Encoder Multilanguage de Google, transformers de Hugging Face o el popular embeddings de OpenAI. La razón es que deseamos comprender qué ocurre en el interior de cada transformer. Si bien es cierto que este no es el transformer definitivo, resulta muy valioso para mostrar las herramientas disponibles y proporcionar una breve explicación de su funcionamiento.

from sklearn.compose import ColumnTransformer
from sklearn.feature_extraction.text import CountVectorizer, TfidfTransformer
from sklearn.pipeline import Pipeline
from sklearn.decomposition import PCA, TruncatedSVD

word_encoder = Pipeline(steps=[
('vectorizer', CountVectorizer(analyzer='word')),
('tfid', TfidfTransformer()),
('reducer', TruncatedSVD(n_components=512, random_state=42))
])

transformers=[('text', word_encoder, 'text')]

# Creamos nuestro transformer
transformer = ColumnTransformer(transformers=transformers)
data_transf = transformer.fit_transform(df[['text']])
  1. CountVectorizer (Vectorizer de Conteo): es una técnica que convierte el texto en una representación numérica contando la frecuencia de las palabras en cada documento. Sirve para crear una matriz que muestra cuántas veces aparece cada palabra en el texto, lo que facilita el análisis.
  2. TfidfTransformer (Transformador TF-IDF): calcula el valor TF-IDF (Frecuencia de Término-Inverso de Frecuencia de Documento) para cada palabra en el texto. Sirve para asignar un peso a cada palabra en función de su importancia en el documento y en el conjunto de datos en general.
  3. TruncatedSVD (Descomposición de Valores Singulares Truncados): es una técnica de reducción de dimensionalidad que reduce la cantidad de características o dimensiones en los datos, manteniendo las más importantes. Sirve para simplificar los datos y reducir el costo computacional de análisis posteriores.
  4. ColumnTransformer (Transformador de Columnas): Combina las transformaciones aplicadas a las columnas de datos específicas. Sirve para aplicar diferentes transformaciones a diferentes columnas del conjunto de datos.

Al final del proceso, data_transf contiene los datos transformados y preprocesados que pueden utilizarse en análisis posteriores. Sirve como entrada para análisis más avanzados, como búsqueda basada en KNN o Annoy.

Búsqueda con KNN (Nearest Neighbors)

Llegó el momento de realizar búsquedas, adentrémonos en el emocionante mundo de K-Nearest Neighbors (KNN) y cómo se aplica a la búsqueda de libros. KNN es como un guía experto que nos ayuda a encontrar tesoros literarios relacionados en un vasto mundo de conocimiento. Veamos cómo funciona en el contexto de la búsqueda.

¿Cómo funciona KNN en la Búsqueda?

KNN se basa en el principio de que objetos similares tienden a agruparse en el mismo espacio. En el caso de la búsqueda de libros, podemos imaginar cada libro representado como un punto en un espacio multidimensional, donde cada dimensión corresponde a una característica o atributo del libro, como género, autor, temas tratados, etc.

Cuando realizamos una búsqueda, KNN identifica los “vecinos más cercanos” en este espacio multidimensional. Estos vecinos son libros que son más similares al libro que estamos buscando en términos de las características específicas que hemos definido.

from sklearn.neighbors import NearestNeighbors

KNN_PARAMETERS_KEYWORDS = {
'radius': 0.5,
'metric': 'cosine'
}

model = NearestNeighbors(**KNN_PARAMETERS_KEYWORDS)
model.fit(data_transf)

Desde la librería scikit-learn haremos uso de la clase NearestNeighbors para entrenar un modelo de búsqueda basado en K-Nearest Neighbors (KNN).

  1. Definimos los parámetros del algoritmo: radius se establece en 0.5, lo que significa que el modelo buscará vecinos dentro de un radio de 0.5 unidades en el espacio multidimensional. metric se establece en 'cosine', lo que indica que el modelo utilizará la métrica de similitud del coseno para medir la distancia entre los puntos.
  2. Creamos un modelo usando NearestNeighbors y le proporcionamos los parámetros necesarios.
  3. Entrenamos el modelo usando método fit de la clase NearestNeighbors

El modelo está listo para buscar vecinos más cercanos en los datos transformados utilizando una métrica de similitud del coseno con un radio específico para determinar qué se considera “cercano” en el espacio multidimensional.


def search(text, top_n=10):
# 1. Preprocesamos los datos
text_clean = TextProcessing(stop_words=stop_words).clean(text)
dfi = pd.DataFrame([{'text': text_clean}])

# 2. Transformamos los datos
text_transformed = transformer.transform(dfi)

# 3. Buscar vecinos más cercanos
distances, indices = model.kneighbors(text_transformed, n_neighbors=top_n)

dfpred = pd.DataFrame()
dfpred['index'] = indices[0]
dfpred['distance'] = distances[0]
dfpred = dfpred.merge(df, on='index', how='left')
dfpred = dfpred.sort_values('distance')
return dfpred[['index', 'distance', 'title', 'author', 'genres']]

En este código, se realiza una búsqueda de libros similares utilizando el modelo K-Nearest Neighbors (KNN). Aquí hay una descripción general de lo que ocurre:

  1. Preprocesamiento de Datos: El texto de búsqueda se limpia y se procesa para eliminar palabras irrelevantes utilizando la misma clase encargada de limpiar el texto llamada TextProcessing.
  2. Transformación de Datos: El texto preprocesado se transforma utilizando el mismo proceso que se aplicó a los datos de entrenamiento. Esto garantiza que el texto de búsqueda esté en el mismo formato que los datos con los que se entrenó el modelo KNN.
  3. Búsqueda de Vecinos Más Cercanos: Se utiliza el modelo KNN previamente entrenado para encontrar los libros más similares al texto de búsqueda en función de las características definidas previamente. Se obtienen los índices y distancias de los vecinos más cercanos.
  4. Organización y Presentación de Resultados: Los resultados se organizan en un DataFrame que contiene información sobre los libros encontrados, como el título, el autor y los géneros. Los resultados se ordenan en función de la distancia, lo que significa que los libros más similares al texto de búsqueda aparecen primero. Además registramos el tiempo que demoró en realizar la búsqueda.

Veamos algunas búsquedas:

# Búsqueda por un autor conocido
search("Agatha Christie")
# Búsqueda por un título conocido

search("Harry Potter")

Dada la naturaleza artesanal y simplicidad de nuestro transformer, los resultados son bastante buenos. Sin embargo el tiempo que demora en realizar todo este proceso es demasiado para un motor de búsqueda en tiempo real.

Acelerando las búsquedas con Annoy

Ahora hablemos de Annoy (Approximate nearest neighbor), ya verán como Annoy se convierte en un aliado clave para acelerar nuestras búsquedas. Annoy es como el motor de búsqueda turbo que lleva nuestras búsquedas al siguiente nivel.

Annoy es una biblioteca especializada en la búsqueda rápida de información similar, creada por Erik Bernhardsson. Fue diseñada con el objetivo de acelerar las búsquedas en grandes conjuntos de datos y se ha convertido en una herramienta esencial para aplicaciones que requieren búsqueda y recuperación eficientes.

¿Cómo es la optimización en Annoy?

Annoy se basa en la técnica de “vecinos más cercanos aproximados”. Construye un índice que organiza los datos de manera eficiente para que las búsquedas sean ultra rápidas. Funciona de la siguiente manera: cuando realizas una búsqueda, Annoy no necesita comparar cada elemento con todos los demás. En su lugar, examina una selección de elementos cercanos y elimina rápidamente aquellos que están demasiado lejos. Esto significa que, incluso en conjuntos de datos masivos, Annoy puede encontrar resultados similares en una fracción del tiempo que llevaría a otros métodos.

Ahora entrenemos un modelo de annoy, para ello usaremos la matriz de datos transformados previamente:

from annoy import AnnoyIndex

model_annoy = AnnoyIndex(512, 'dot')

for i in df.index.tolist():
v = data_transf[i]
model_annoy.add_item(i, v)
model_annoy.build(10)

En resumen, este código configura y construye un índice Annoy utilizando las representaciones vectoriales de los datos transformados. Este índice permitirá búsquedas eficientes basadas en la similitud de vectores en un espacio multidimensional de 512 dimensiones.

def search_with_annoy(text, model, transformer, top_n=10):

# 1. Preprocess data
text_clean = TextProcessing(stop_words=stop_words).clean(text)
dfi = pd.DataFrame([{'text': text_clean}])

# 2. Transform data
text_transformed = transformer.transform(dfi)

# 3. Buscar vecinos más cercanos
indices, distances = model.get_nns_by_vector(text_transformed[0],n=top_n, search_k=-1, include_distances=True)

dfpred = pd.DataFrame()
dfpred['book_index'] = indices
dfpred['distance'] = distances
dfpred = dfpred.merge(df, on='book_index', how='left')
dfpred = dfpred.sort_values('distance')

return dfpred[['book_index', 'distance', 'title', 'author', 'genres']]

Veamos algunas búsquedas:

También podríamos buscar por autor o por género:

Comparando tiempos de respuesta para ambos modelos

Time (ms) vs Top n

Como podemos observar en los resultados, Annoy tiene un tiempo promedio de respuesta de 26ms mientras que KNN tiene un tiempo promedio de respuesta de 120ms. Esto significa que Annoy es 3–4 veces más rápido que KNN para la búsqueda, lo que lo hace indiscutiblemente una muy buena opción.

Conclusión

En este artículo, exploramos la creación de un transformador de texto utilizando técnicas simples de Procesamiento de Lenguaje Natural (NLP). Si bien los resultados obtenidos son prometedores, es importante destacar que las alternativas avanzadas, como el Universal Sentence Encoder de Google o los embeddings de OpenAI, tienen el potencial de llevar la precisión y la eficiencia de la búsqueda a un nivel aún más alto.

Además, demostramos lo sencillo que puede ser crear un modelo de K-Nearest Neighbors (KNN) para encontrar elementos similares en un conjunto de datos. Los resultados obtenidos con KNN fueron sorprendentemente buenos, lo que destaca la simplicidad y efectividad de este enfoque.

Finalmente, experimentamos con la optimización de la velocidad de búsqueda al incorporar Annoy en nuestro flujo de trabajo. Los resultados fueron impactantes, ya que Annoy aceleró nuestras búsquedas aproximadamente 3–4 veces en comparación con KNN. Esta mejora significativa en la velocidad de búsqueda es fundamental para brindar una experiencia eficiente a los usuarios, sin importar el tamaño de la base de datos.

En resumen, hemos explorado y experimentado con varias tecnologías, desde NLP hasta KNN y Annoy, y hemos demostrado cómo estas herramientas pueden trabajar en conjunto para mejorar la precisión y la velocidad de los motores de búsqueda.

Si ésto les pareció sorprendente como a mí! Prepárate para nuestro próximo artículo, donde exploraremos cómo los embeddings de OpenAI y las bases de datos vectoriales llevan todo lo que hemos visto en este artículo a un nivel completamente nuevo. Te aseguro que lo que viene te dejará sin palabras. ¡No te lo pierdas!

--

--