Image for post Construyendo un Motor de Búsqueda Básico: TF-IDF y BM25 en Python

Construyendo un Motor de Búsqueda Básico: TF-IDF y BM25 en Python


Como desarrolladores, a menudo nos enfrentamos a la necesidad de encontrar información relevante dentro de grandes volúmenes de texto. Ya sea en una base de datos de documentos, un catálogo de productos o un archivo de correos electrónicos, la búsqueda eficiente y precisa es fundamental. Sin embargo, una simple búsqueda de texto plano, como usar el operador in de Python o la función find(), rápidamente se queda corta. Estas herramientas solo nos dicen si una palabra existe, no cuán importante es esa palabra en el contexto del documento o cuán relevante es el documento para nuestra consulta.

Aquí es donde entran en juego los motores de búsqueda. Su objetivo principal es, dada una consulta, devolver los documentos más relevantes de un corpus. Este artículo te guiará a través de los conceptos fundamentales y la implementación práctica de dos algoritmos clásicos y potentes para la recuperación de información: TF-IDF y BM25. Aprenderás a construir un motor de búsqueda básico desde cero, sentando las bases para sistemas más complejos.

Contexto del Problema: Más Allá de la Búsqueda Simple

Imagina que tienes una colección de artículos de noticias y quieres encontrar aquellos que hablen sobre "inteligencia artificial". Si simplemente buscas la frase "inteligencia artificial" en cada artículo, obtendrás todos los documentos que la contengan. Pero, ¿cuál es el más relevante? ¿El que la menciona una vez en el pie de página o el que la discute extensamente en cada párrafo?

La búsqueda de texto plano tiene varias limitaciones:

  • Falta de Relevancia: No distingue la importancia de un término dentro de un documento o en el corpus general.
  • Sensibilidad a la Forma de la Palabra: "correr", "corriendo", "corrió" son tratadas como palabras distintas, aunque semánticamente estén relacionadas.
  • Ignora el Contexto: No entiende sinónimos ni relaciones semánticas entre palabras.
  • Escalabilidad Pobre: Para grandes volúmenes de datos, buscar linealmente es ineficiente.

Para superar estas limitaciones, necesitamos métodos que puedan:

  • Cuantificar la importancia de las palabras.
  • Comparar la "similitud" entre una consulta y un documento.
  • Clasificar los resultados por relevancia.

Conceptos Clave para la Recuperación de Información

Antes de sumergirnos en los algoritmos, es crucial entender algunos conceptos básicos del Procesamiento del Lenguaje Natural (PLN) y la recuperación de información.

Tokenización

La tokenización es el proceso de dividir un texto en unidades más pequeñas llamadas "tokens". Generalmente, un token es una palabra, pero también pueden ser números, signos de puntuación o incluso subpalabras, dependiendo de la estrategia. Por ejemplo, la frase "Hola, mundo!" podría tokenizarse en ["Hola", ",", "mundo", "!"] o simplemente ["Hola", "mundo"]. Para la búsqueda, nos interesan principalmente las palabras.

Stop Words

Las "stop words" (palabras vacías) son palabras muy comunes en un idioma (como "el", "la", "un", "y", "de", "en") que, por sí solas, no suelen aportar mucho significado o relevancia a la hora de determinar el tema principal de un documento. Eliminarlas ayuda a reducir el ruido y a enfocar el análisis en las palabras más significativas.

Stemming y Lemmatization (Breve)

Estos son procesos para reducir las palabras a su forma base. El stemming (derivación) corta los sufijos de las palabras para llegar a una "raíz" (ej. "corriendo" -> "corr"). Es un proceso heurístico y a veces produce raíces que no son palabras reales. La lemmatization (lematización) es un proceso más sofisticado que utiliza un vocabulario y un análisis morfológico para devolver la forma base o "lema" de una palabra (ej. "corriendo" -> "correr"). Para este artículo, nos centraremos en la tokenización y eliminación de stop words para mantener la simplicidad, pero es importante conocer estas técnicas para mejoras futuras.

TF (Term Frequency - Frecuencia del Término)

La Frecuencia del Término (TF) mide la frecuencia con la que un término (palabra) aparece en un documento. Intuitivamente, si una palabra aparece muchas veces en un documento, es probable que ese documento sea relevante para esa palabra. Se calcula como:

TF(t, d) = (Número de veces que el término 't' aparece en el documento 'd') / (Número total de términos en el documento 'd')

Normalizar por la longitud del documento evita que los documentos más largos tengan inherentemente TFs más altos.

IDF (Inverse Document Frequency - Frecuencia Inversa del Documento)

La Frecuencia Inversa del Documento (IDF) mide la importancia de un término en todo el corpus. Si un término aparece en muchos documentos, es menos distintivo y, por lo tanto, menos útil para diferenciar documentos. Por el contrario, si un término aparece en pocos documentos, es más distintivo y, por lo tanto, más importante. Se calcula como:

IDF(t, D) = log( (Número total de documentos en el corpus 'D') / (Número de documentos que contienen el término 't') )

Se usa el logaritmo para suavizar el impacto y evitar divisiones por cero si un término no aparece en ningún documento.

TF-IDF

TF-IDF es el producto de TF e IDF. Combina la importancia de un término dentro de un documento con su importancia en todo el corpus. Un valor alto de TF-IDF para un término en un documento sugiere que el término es frecuente en ese documento (TF alto) pero no tan frecuente en el resto del corpus (IDF alto), lo que lo convierte en un buen indicador de la relevancia del documento para ese término.

TF-IDF(t, d, D) = TF(t, d) * IDF(t, D)

BM25 (Okapi BM25)

BM25 es un algoritmo de clasificación de documentos que es una mejora sobre TF-IDF y es ampliamente utilizado en motores de búsqueda. Aunque comparte la intuición de TF-IDF (frecuencia del término en el documento y rareza en el corpus), introduce una función de saturación para la frecuencia del término y normaliza por la longitud del documento de una manera más sofisticada. Esto significa que un término que aparece 100 veces no es necesariamente 100 veces más importante que uno que aparece una vez; su importancia se satura a partir de cierto punto. Además, penaliza los documentos muy largos que contienen el término pero que no son tan relevantes en general.

La fórmula de BM25 para un término t en un documento d, dada una consulta Q, es:

Score(Q, d) = Σ (IDF(t) * ( (f(t,d) * (k1 + 1)) / (f(t,d) + k1 * (1 - b + b * (len(d) / avg_len_doc))) ) )

Donde:

  • f(t,d) es la frecuencia del término t en el documento d.
  • IDF(t) es la Frecuencia Inversa del Documento para el término t (calculada de forma similar a TF-IDF, pero con algunas variaciones).
  • k1 es un parámetro de saturación de la frecuencia del término (comúnmente entre 1.2 y 2.0).
  • b es un parámetro de normalización de la longitud del documento (comúnmente alrededor de 0.75).
  • len(d) es la longitud del documento d (número de palabras).
  • avg_len_doc es la longitud promedio de los documentos en el corpus.

BM25 es más robusto y a menudo produce mejores resultados que TF-IDF simple en la práctica.

Implementación Paso a Paso en Python

Vamos a construir nuestro motor de búsqueda paso a paso. Necesitaremos la librería nltk para el preprocesamiento de texto y math para los cálculos logarítmicos.

Paso 1: Preparación del Entorno y Preprocesamiento de Texto

Primero, asegúrate de tener nltk instalado (pip install nltk). También necesitarás descargar los recursos de stopwords y el tokenizador de palabras.


import nltk
import os
import re
from collections import Counter
from math import log

# Descargar recursos de NLTK si no están presentes
# En un entorno de producción, esto se haría una vez o se asegurarían los recursos en el path de NLTK_DATA
# Para este ejemplo, lo descargamos si es necesario.
try:
    nltk.data.find('corpora/stopwords')
except nltk.downloader.DownloadError:
    nltk.download('stopwords')
try:
    nltk.data.find('tokenizers/punkt')
except nltk.downloader.DownloadError:
    nltk.download('punkt')

from nltk.corpus import stopwords
from nltk.tokenize import word_tokenize

# Definimos las stopwords en español
SPANISH_STOPWORDS = set(stopwords.words('spanish'))

def preprocess_text(text):
    """
    Tokeniza el texto, convierte a minúsculas, elimina stopwords y caracteres no alfanuméricos.
    """
    text = text.lower() # Convertir a minúsculas
    # Eliminar caracteres no alfanuméricos y números, manteniendo solo letras y espacios
    # Se usa [^a-záéíóúüñ\s] para incluir caracteres especiales del español y espacios
    text = re.sub(r'[^a-záéíóúüñ\s]', '', text) 
    tokens = word_tokenize(text, language='spanish') # Tokenizar
    # Filtrar stopwords
    filtered_tokens = [word for word in tokens if word not in SPANISH_STOPWORDS and len(word) > 1] # Eliminar palabras de 1 letra
    return filtered_tokens

En el código anterior, usamos nltk.download(). En un entorno de producción, es mejor asegurar que estos recursos estén disponibles en el NLTK_DATA_PATH (que se puede configurar con una variable de entorno) o descargarlos una única vez durante la configuración del sistema, en lugar de cada vez que se ejecuta el script. Para este tutorial, la comprobación y descarga automática es conveniente.

Paso 2: Cálculo de TF-IDF (Manual)

Aunque scikit-learn tiene un TfidfVectorizer, implementaremos las funciones manualmente para entender mejor los conceptos. Primero, necesitamos calcular la frecuencia de términos (TF) para cada documento y la frecuencia de documentos (DF) para todo el corpus.


class SearchEngine:
    def __init__(self, documents):
        self.documents = documents
        self.corpus = [preprocess_text(doc) for doc in documents]
        self.doc_lengths = [len(doc) for doc in self.corpus]
        self.avg_doc_length = sum(self.doc_lengths) / len(self.doc_lengths)
        self.vocab = self._build_vocabulary()
        self.df = self._calculate_df()

    def _build_vocabulary(self):
        vocab = set()
        for doc_tokens in self.corpus:
            vocab.update(doc_tokens)
        return sorted(list(vocab))

    def _calculate_df(self):
        df = Counter()
        for doc_tokens in self.corpus:
            for term in set(doc_tokens): # Contar solo una vez por documento
                df[term] += 1
        return df

    def _calculate_tf(self, doc_tokens):
        tf = Counter(doc_tokens)
        total_terms = len(doc_tokens)
        if total_terms == 0: # Evitar división por cero para documentos vacíos
            return {term: 0.0 for term in tf}
        return {term: count / total_terms for term, count in tf.items()}

    def _calculate_idf(self, term):
        # Evitar división por cero si el término no está en ningún documento
        if self.df[term] == 0:
            return 0.0
        # Se añade +1 al numerador y denominador para evitar log(0) y log(1)=0 en casos extremos
        return log(len(self.corpus) / (self.df[term] + 1))

    def get_tfidf_score(self, term, doc_index):
        doc_tokens = self.corpus[doc_index]
        tf = self._calculate_tf(doc_tokens).get(term, 0.0)
        idf = self._calculate_idf(term)
        return tf * idf

    def search_tfidf(self, query, top_n=5):
        query_tokens = preprocess_text(query)
        scores = {i: 0.0 for i in range(len(self.documents))}

        for i, doc_tokens in enumerate(self.corpus):
            for term in query_tokens:
                scores[i] += self.get_tfidf_score(term, i)
        
        # Ordenar documentos por score y devolver los índices de los top_n
        sorted_docs = sorted(scores.items(), key=lambda item: item[1], reverse=True)
        return [(self.documents[idx], score) for idx, score in sorted_docs[:top_n] if score > 0]

Paso 3: Implementación de BM25

Ahora, implementaremos la lógica de BM25 dentro de nuestra clase SearchEngine. Necesitaremos los parámetros k1 y b. Para este ejemplo, los definiremos como constantes, pero en una aplicación real, estos podrían ser configurables, quizás cargados desde variables de entorno o un archivo de configuración.


    # Parámetros de BM25 (pueden ser ajustados)
    # Se usan os.getenv para demostrar cómo se cargarían en un entorno real
    BM25_K1 = float(os.getenv('BM25_K1', '1.5')) # Default 1.5, rango común 1.2-2.0
    BM25_B = float(os.getenv('BM25_B', '0.75')) # Default 0.75, rango común 0.5-0.8

    def _calculate_bm25_idf(self, term):
        # IDF para BM25, a menudo con un +0.5 en el denominador para evitar log(0)
        # y un +1 en el numerador para evitar log(1) = 0 si el término está en todos los documentos.
        n_docs_with_term = self.df[term]
        if n_docs_with_term == 0:
            return 0.0
        # Fórmula de IDF para BM25, con suavizado
        return log((len(self.corpus) - n_docs_with_term + 0.5) / (n_docs_with_term + 0.5) + 1)

    def get_bm25_score(self, term, doc_index):
        doc_tokens = self.corpus[doc_index]
        term_frequency_in_doc = Counter(doc_tokens).get(term, 0)
        doc_length = self.doc_lengths[doc_index]

        idf = self._calculate_bm25_idf(term)

        # Componente de frecuencia del término saturada
        numerator = term_frequency_in_doc * (self.BM25_K1 + 1)
        denominator = term_frequency_in_doc + self.BM25_K1 * (1 - self.BM25_B + self.BM25_B * (doc_length / self.avg_doc_length))

        if denominator == 0: # Evitar división por cero
            return 0.0

        return idf * (numerator / denominator)

    def search_bm25(self, query, top_n=5):
        query_tokens = preprocess_text(query)
        scores = {i: 0.0 for i in range(len(self.documents))}

        for i, doc_tokens in enumerate(self.corpus):
            for term in query_tokens:
                scores[i] += self.get_bm25_score(term, i)
        
        sorted_docs = sorted(scores.items(), key=lambda item: item[1], reverse=True)
        return [(self.documents[idx], score) for idx, score in sorted_docs[:top_n] if score > 0]

Aquí hemos introducido os.getenv para los parámetros BM25_K1 y BM25_B. Esto es una buena práctica para hacer que tu código sea configurable sin "hardcodear" valores, permitiendo ajustarlos fácilmente en diferentes entornos (desarrollo, producción) o para experimentar con su rendimiento. Si las variables de entorno no están definidas, se usan valores por defecto.

Mini Proyecto: Un Buscador de Artículos Simple

Ahora, pongamos todo junto para crear un pequeño buscador de artículos. Usaremos un corpus de ejemplo y probaremos nuestras funciones de búsqueda.


# --- Continuación de la clase SearchEngine y funciones de preprocesamiento ---
# Asegúrate de que todo el código anterior esté en el mismo script o archivo.

if __name__ == "__main__":
    # Corpus de documentos de ejemplo
    documents = [
        "La inteligencia artificial está transformando el mundo de la tecnología.",
        "El aprendizaje automático es una rama de la inteligencia artificial.",
        "Python es un lenguaje de programación muy popular para el desarrollo de IA y ML.",
        "Los modelos de lenguaje grandes (LLMs) son un avance clave en la IA moderna.",
        "La ciencia de datos combina estadísticas, programación y conocimiento del dominio.",
        "El desarrollo web con Python y frameworks como Django o Flask es muy común.",
        "La robótica y la visión por computadora son campos emocionantes de la inteligencia artificial."
    ]

    print("Inicializando motor de búsqueda...")
    search_engine = SearchEngine(documents)
    print("Motor de búsqueda inicializado con {} documentos.".format(len(documents)))

    print("\n--- Búsqueda con TF-IDF ---")
    query_tfidf = "inteligencia artificial"
    print(f"Consulta: '{query_tfidf}'")
    results_tfidf = search_engine.search_tfidf(query_tfidf)
    if results_tfidf:
        for doc, score in results_tfidf:
            print(f"  [Score: {score:.4f}] {doc}")
    else:
        print("  No se encontraron resultados para esta consulta.")

    print("\n--- Búsqueda con BM25 ---")
    query_bm25 = "python inteligencia artificial"
    print(f"Consulta: '{query_bm25}'")
    results_bm25 = search_engine.search_bm25(query_bm25)
    if results_bm25:
        for doc, score in results_bm25:
            print(f"  [Score: {score:.4f}] {doc}")
    else:
        print("  No se encontraron resultados para esta consulta.")

    print("\n--- Otra consulta con BM25 ---")
    query_bm25_2 = "desarrollo web python"
    print(f"Consulta: '{query_bm25_2}'")
    results_bm25_2 = search_engine.search_bm25(query_bm25_2)
    if results_bm25_2:
        for doc, score in results_bm25_2:
            print(f"  [Score: {score:.4f}] {doc}")
    else:
        print("  No se encontraron resultados para esta consulta.")

    # Ejemplo de cómo establecer variables de entorno para probar BM25
    # Descomenta y ejecuta para probar diferentes parámetros
    # import os
    # os.environ['BM25_K1'] = '1.8'
    # os.environ['BM25_B'] = '0.6'
    # print("\n--- BM25 con K1=1.8, B=0.6 (ajustado via env vars) ---")
    # search_engine_tuned = SearchEngine(documents) # Re-inicializar para aplicar nuevos env vars
    # results_tuned = search_engine_tuned.search_bm25(query_bm25)
    # if results_tuned:
    #     for doc, score in results_tuned:
    #         print(f"  [Score: {score:.4f}] {doc}")

Para ejecutar este código, guárdalo como un archivo .py (ej. buscador.py) y ejecútalo desde tu terminal: python buscador.py. Verás cómo los documentos se clasifican según la relevancia calculada por TF-IDF y BM25. Observa cómo BM25, con sus parámetros ajustables, puede ofrecer una mejor discriminación de la relevancia.

Errores Comunes y Depuración

Al construir un motor de búsqueda, especialmente uno básico, te puedes encontrar con algunos problemas:

  • Recursos de NLTK no Descargados: Si olvidas ejecutar nltk.download('stopwords') o nltk.download('punkt'), obtendrás errores al intentar usar estos recursos. Asegúrate de que se descarguen una vez.
  • Preprocesamiento Inconsistente: Es crucial aplicar exactamente el mismo preprocesamiento (tokenización, minúsculas, eliminación de stopwords) tanto a los documentos del corpus como a las consultas. Cualquier inconsistencia resultará en términos que no coinciden y, por lo tanto, en resultados de búsqueda pobres.
  • Documentos o Consultas Vacías: Si un documento o una consulta, después del preprocesamiento, queda vacío (ej. solo contenía stopwords), esto puede causar errores de división por cero en los cálculos de TF o longitud promedio. Nuestro código ya maneja esto en _calculate_tf y get_bm25_score.
  • Parámetros BM25 (k1, b) Incorrectos: Los valores de k1 y b son empíricos y pueden necesitar ajustarse para tu corpus específico. Valores muy altos o muy bajos pueden llevar a un rendimiento subóptimo. Experimenta con ellos para ver cómo afectan los resultados.
  • Problemas de Rendimiento con Grandes Corpus: Para un corpus muy grande, calcular TF-IDF o BM25 para cada consulta en tiempo real puede ser lento. En sistemas reales, el índice (DF, IDF, etc.) se precalcula y se almacena, y solo se calculan los scores para los términos de la consulta.

Aprendizaje Futuro y Próximos Pasos

Has construido un motor de búsqueda funcional, pero esto es solo el principio. Aquí hay algunas áreas para explorar y mejorar:

  • Stemming y Lemmatization Avanzados: Integra un lematizador (como el de spaCy o NLTK con WordNetLemmatizer) para mejorar la coincidencia de términos relacionados.
  • Manejo de Sinónimos y Expansión de Consultas: Utiliza diccionarios de sinónimos o modelos de embeddings para expandir las consultas y encontrar documentos que usen palabras relacionadas pero no idénticas.
  • Construcción de un Índice Invertido: Para mejorar el rendimiento en corpus grandes, implementa un índice invertido que mapee cada término a la lista de documentos que lo contienen y sus frecuencias. Esto acelera enormemente la recuperación de documentos relevantes.
  • Integración con FastAPI: Convierte tu motor de búsqueda en una API RESTful usando FastAPI, permitiendo que otras aplicaciones consulten tu índice.
  • Búsqueda Semántica con Embeddings: Explora cómo los modelos de lenguaje modernos pueden generar "embeddings" (representaciones vectoriales) de texto. Puedes usar estos embeddings con bases de datos vectoriales (como ChromaDB, Pinecone) para realizar búsquedas basadas en el significado, no solo en la coincidencia de palabras clave. Esto es un salto cualitativo en la relevancia.
  • Optimización de Rendimiento: Para corpus muy grandes, considera el uso de librerías optimizadas como Gensim para TF-IDF o soluciones de búsqueda dedicadas como Elasticsearch o Apache Solr.

Dominar los fundamentos de TF-IDF y BM25 te proporciona una base sólida para entender y construir sistemas de recuperación de información más avanzados. ¡Sigue explorando y construyendo!