¿Cómo recuperan información BM25 y RAG de manera diferente?

Cuando escribes una consulta en un motor de búsqueda, algo tiene que decidir qué documentos son realmente relevantes y cómo clasificarlos. BM25 (Best Matching 25), el algoritmo que impulsa motores de búsqueda como Elasticsearch y Lucene, ha sido la respuesta dominante a esa pregunta durante décadas.

Califica los documentos observando tres cosas: con qué frecuencia aparecen los términos de consulta en un documento, qué tan raros son esos términos en toda la colección y si un documento es inusualmente largo. La parte inteligente es que BM25 no recompensa el exceso de palabras clave: una palabra que aparece 20 veces no hace que un documento sea 20 veces más relevante, gracias a la saturación de frecuencia de términos. Pero BM25 tiene un punto ciego fundamental: sólo coincide con las palabras que usted escribió, no con lo que quiso decir. Busque “encontrar contenido similar sin superposición de palabras exactas” y BM25 le devolverá una mirada en blanco.

Esta es exactamente la brecha que se creó para llenar la Generación Aumentada de Recuperación (RAG) con incrustaciones de vectores: haciendo coincidir el significado, no solo las palabras clave. En este artículo, analizaremos cómo funciona cada enfoque, dónde gana cada uno y por qué los sistemas de producción utilizan cada vez más ambos juntos.

Cómo funciona BM25

Básicamente, BM25 asigna una puntuación de relevancia a cada documento de la colección para una consulta determinada y luego clasifica los documentos según esa puntuación. Para cada término de su consulta, BM25 plantea tres preguntas: ¿Con qué frecuencia aparece este término en el documento? ¿Qué tan raro es este término en todos los documentos? ¿Y este documento es inusualmente largo? La puntuación final es la suma de las respuestas ponderadas a estas preguntas en todos los términos de consulta.

El término componente de frecuencia es donde el BM25 se vuelve inteligente. En lugar de contar las ocurrencias brutas, aplica la saturación: la puntuación crece rápidamente al principio, pero se estabiliza a medida que aumenta la frecuencia. Un término que aparece 5 veces aporta mucho más que uno que aparece una vez, pero un término que aparece 50 veces aporta poco más que uno que aparece 20 veces. Esto está controlado por el parámetro k₁ (normalmente establecido entre 1,2 y 2,0). Si lo pones bajo, la saturación se activará rápidamente; configúrelo alto y la frecuencia bruta importa más. Esta única elección de diseño es lo que hace que BM25 sea resistente al exceso de palabras clave: repetir una palabra cien veces en un documento no mejorará la puntuación.

Normalización de longitud e IDF

El segundo parámetro de ajuste, b (normalmente 0,75), controla en qué medida se penaliza la longitud de un documento. Naturalmente, un documento largo contiene más palabras, por lo que tiene más posibilidades de incluir el término de consulta, no porque sea más relevante, sino simplemente porque es más largo. BM25 compara la longitud de cada documento con la longitud promedio de los documentos en la colección y reduce la puntuación de frecuencia del término en consecuencia. Establecer b = 0 desactiva esta penalización por completo; b = 1 aplica la normalización completa.

Finalmente, IDF (Frecuencia Inversa de Documentos) garantiza que los términos raros tengan más peso que los comunes. Si la palabra “recuperación” aparece sólo en 3 de cada 10.000 documentos, es una fuerte señal de relevancia cuando se compara. Si la palabra “el” aparece en los 10.000, hacerla coincidir no dice casi nada. IDF es lo que hace que BM25 preste atención a las palabras que realmente discriminan entre documentos. Una advertencia importante: debido a que BM25 opera exclusivamente en función de la frecuencia de los términos, no tiene conocimiento del orden de las palabras, el contexto o el significado: hacer coincidir “banco” en una consulta sobre finanzas y “banco” en un documento sobre ríos parece idéntico a BM25. Esa limitación de la bolsa de palabras es fundamental, no un problema de sintonización.

BM25 y la búsqueda vectorial responden a la misma pregunta: ¿qué documentos son relevantes para esta consulta? – pero a través de lentes fundamentalmente diferentes. BM25 es un algoritmo de concordancia de palabras clave: busca las palabras exactas de su consulta dentro de cada documento, las califica según la frecuencia y la rareza, y las clasifica en consecuencia. No comprende el lenguaje: ve el texto como una bolsa de fichas, no como significado.

La búsqueda de vectores, por el contrario, convierte tanto la consulta como cada documento en vectores numéricos densos utilizando un modelo de incrustación, luego encuentra documentos cuyos vectores apuntan en la misma dirección que el vector de consulta, medido por la similitud del coseno. Esto significa que la búsqueda de vectores puede hacer coincidir “paro cardíaco” con un documento sobre “insuficiencia cardíaca” aunque ninguna de las palabras se superponga, porque el modelo de incorporación ha aprendido que estos conceptos viven juntos en el espacio semántico.

La compensación es práctica: BM25 no requiere ningún modelo, GPU ni llamada API: es rápido, liviano y completamente explicable. La búsqueda vectorial requiere un modelo integrado en el momento del índice y en el momento de la consulta, agrega latencia y costo y produce puntuaciones que son más difíciles de interpretar. Ninguno de los dos es estrictamente mejor; fallan en direcciones opuestas, que es exactamente la razón por la que la búsqueda híbrida (que combina ambas) se ha convertido en el estándar de producción.

Comparación de BM25 y búsqueda vectorial en Python

Instalando las dependencias

instalación de pip rank_bm25 openai numpy
importar matemáticas importar re importar numpy como np de colecciones importar contador de rango_bm25 importar BM25Okapi de openai importar OpenAI
importar sistema operativo desde getpass importar getpass os.environ[‘OPENAI_API_KEY’] = getpass(‘Ingrese la clave API de OpenAI: ‘)

Definiendo el corpus

Antes de comparar BM25 y la búsqueda vectorial, necesitamos una base de conocimientos compartida para realizar la búsqueda. Definimos 12 fragmentos de texto breves que cubren una variedad de temas: Python, aprendizaje automático, BM25, transformadores, incrustaciones, RAG, bases de datos y más. Los temas se varían deliberadamente: algunos fragmentos están estrechamente relacionados (BM25 y TF-IDF, incrustaciones y similitud de coseno), mientras que otros no tienen ninguna relación (PostgreSQL, Django). Esta variedad es lo que hace que la comparación sea significativa: un método de recuperación que funcione bien debería mostrar los fragmentos relevantes e ignorar el ruido.

Este corpus actúa como nuestro sustituto de un almacén de documentos real. En una canalización RAG de producción, estos fragmentos provendrían de dividir y limpiar documentos reales: PDF, wikis, bases de conocimiento. Aquí, los mantenemos breves y elaborados a mano para que el comportamiento de recuperación sea fácil de rastrear y razonar.

TROZOS = [
# 0
“Python is a high-level, interpreted programming language known for its simple and readable syntax. ”
“It supports multiple programming paradigms including procedural, object-oriented, and functional programming.”,

# 1
“Machine learning is a subset of artificial intelligence that enables systems to learn from data ”
“without being explicitly programmed. Common algorithms include linear regression, decision trees, and neural networks.”,

# 2
“BM25 stands for Best Match 25. It is a bag-of-words retrieval function used by search engines ”
“to rank documents based on the query terms appearing in each document. ”
“BM25 uses term frequency and inverse document frequency with length normalization.”,

# 3
“Transformer architecture introduced the self-attention mechanism, which allows the model to weigh ”
“the importance of different words in a sentence regardless of their position. ”
“BERT and GPT are both based on the Transformer architecture.”,

# 4
“Vector embeddings represent text as dense numerical vectors in a high-dimensional space. ”
“Similar texts are placed closer together. This allows semantic search — finding documents ”
“that mean the same thing even if they use different words.”,

# 5
“TF-IDF stands for Term Frequency-Inverse Document Frequency. It reflects how important a word is ”
“to a document relative to the entire corpus. Rare words get higher scores than common ones like ‘the’.”,

# 6
“Retrieval-Augmented Generation (RAG) combines a retrieval system with a language model. ”
“The retriever finds relevant documents; the generator uses them as context to produce an answer. ”
“This reduces hallucinations and allows the model to cite sources.”,

# 7
“Django is a high-level Python web framework that encourages rapid development and clean, pragmatic design. ”
“It includes an ORM, authentication system, and admin panel out of the box.”,

# 8
“Cosine similarity measures the angle between two vectors. A score of 1 means identical direction, ”
“0 means orthogonal, and -1 means opposite. It is commonly used to compare text embeddings.”,

# 9
“Gradient descent is an optimization algorithm used to minimize a loss function by iteratively ”
“moving in the direction of the steepest descent. It is the backbone of training neural networks.”,

# 10
“PostgreSQL is an open-source relational database known for its robustness and support for advanced ”
“SQL features like window functions, CTEs, and JSON storage.”,

# 11
“Sparse retrieval methods like BM25 rely on exact keyword matches and fail when the query uses ”
“synonyms or paraphrases not present in the document. Dense retrieval using embeddings handles ”
“this by matching semantic meaning rather than surface form.”,
]

print(f”Corpus cargado: {len(CHUNKS)} fragmentos”) para i, c en enumerar(CHUNKS): print(f” [{i:02d}] {do[:75]}…”)

Construyendo el perro perdiguero BM25

Con el corpus definido, podemos construir el índice BM25. El proceso tiene dos pasos: tokenización e indexación. La función tokenize pone el texto en minúsculas y lo divide en cualquier carácter no alfanumérico, por lo que “TF-IDF” se convierte en [“tf”, “idf”] y “bolsa de palabras” se convierte [“bag”, “of”, “words”]. Esto es intencionalmente simple: BM25 es un modelo de bolsa de palabras, por lo que no hay derivación, eliminación de palabras vacías ni preprocesamiento lingüístico. Cada palabra se trata como un token independiente.

Una vez que se tokeniza cada fragmento, BM25Okapi crea el índice: calcula la longitud de los documentos, la longitud promedio de los documentos y las puntuaciones IDF para cada término único del corpus. Esto sucede una vez al inicio. En el momento de la consulta, bm25_search tokeniza la consulta entrante de la misma manera, llama a get_scores para calcular una puntuación de relevancia de BM25 para cada fragmento en paralelo, luego ordena y devuelve los k resultados principales. La verificación de cordura en la parte inferior ejecuta una consulta de prueba para confirmar que el índice está funcionando antes de pasar al recuperador de incrustación.

def tokenize(texto: str) -> lista[str]: “””Minúsculas y división en caracteres no alfanuméricos.””” return re.findall(r’\w+’, text.lower()) # Construya el índice BM25 sobre el corpus tokenized_corpus = [tokenize(chunk) for chunk in CHUNKS]
bm25 = BM25Okapi(tokenized_corpus) def bm25_search(consulta: str, top_k: int = 3) -> lista[dict]: “””Devuelve los k fragmentos principales clasificados según la puntuación de BM25.””” tokens = tokenize(consulta) puntuaciones = bm25.get_scores(tokens) clasificados = np.argsort(puntuaciones)[::-1][:top_k]

devolver [
{“chunk_id”: int(i), “score”: round(float(scores[i]), 4), “texto”: TROZOS[i]} para i en la clasificación ]# Resultados rápidos de la verificación de cordura = bm25_search(“¿Cómo clasifica BM25 los documentos?”, top_k=3) print(“Prueba de BM25 – consulta: ‘¿Cómo clasifica BM25 los documentos'”) para r en los resultados: print(f” [{r[‘chunk_id’]}]puntuación={r[‘score’]} {r[‘text’][:70]}…”)

Construyendo el recuperador de incrustación

El recuperador de incrustación funciona de forma diferente al BM25 en cada paso. En lugar de contar tokens, convierte cada fragmento en un vector numérico denso (una lista de 1.536 números) utilizando el modelo de 3 pequeñas incrustaciones de texto de OpenAI. Cada número representa una dimensión en el espacio semántico, y fragmentos que significan cosas similares terminan con vectores que apuntan en direcciones similares, independientemente de las palabras que utilicen.

El paso de creación del índice llama a la API de incrustación una vez por fragmento y almacena los vectores resultantes en la memoria. Esta es la diferencia clave de costos con respecto a BM25: crear el índice BM25 es pura aritmética en su propia máquina, mientras que crear el índice incrustado requiere una llamada API por fragmento y produce vectores que necesita almacenar. Para 12 trozos esto es trivial; en un millón de partes, esto se convierte en una verdadera decisión de infraestructura.

En el momento de la consulta, embedding_search incrusta la consulta entrante usando el mismo modelo (esto es importante, la consulta y los fragmentos deben vivir en el mismo espacio vectorial) y luego calcula la similitud del coseno entre el vector de consulta y cada vector de fragmento almacenado. La similitud del coseno mide el ángulo entre dos vectores: una puntuación de 1 significa dirección idéntica, 0 significa completamente sin relación y los valores negativos significan significado opuesto. Luego, los fragmentos se clasifican según esta puntuación y se devuelven los k superiores. Aquí también se ejecuta la misma consulta de verificación de cordura de la sección BM25, por lo que puede ver la primera comparación directa entre los dos enfoques con una entrada idéntica.

EMBED_MODEL = “text-embedding-3-small” def get_embedding(text: str) -> np.ndarray: respuesta = client.embeddings.create(model=EMBED_MODEL, input=text) return np.array(response.data[0].embedding) def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float: return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))) # Incrustar todos los fragmentos una vez (este es el paso de “construcción de índice” en RAG) print(“Construyendo índice de incrustación… (una llamada API por trozo)”) trozo_embeddings = [get_embedding(chunk) for chunk in CHUNKS]
print(f”Listo. Cada incrustación tiene {len(chunk_embeddings[0])} dimensiones.”) def embedding_search(consulta: str, top_k: int = 3) -> lista[dict]: “””Devuelve los k fragmentos principales clasificados por similitud de coseno con la incrustación de la consulta.””” query_emb = get_embedding(query) puntuaciones = [cosine_similarity(query_emb, emb) for emb in chunk_embeddings]
clasificado = np.argsort(puntuaciones)[::-1][:top_k]

devolver [
{“chunk_id”: int(i), “score”: round(float(scores[i]), 4), “texto”: TROZOS[i]} para i en la clasificación ]# Resultados rápidos de la verificación de cordura = embedding_search(“¿Cómo clasifica BM25 los documentos?”, top_k=3) print(“\nPrueba de incrustación – consulta: ‘¿Cómo clasifica BM25 los documentos'”) para r en los resultados: print(f” [{r[‘chunk_id’]}]puntuación={r[‘score’]} {r[‘text’][:70]}…”)

Función de comparación lado a lado

Este es el núcleo del experimento. La función de comparación ejecuta la misma consulta a través de ambos recuperadores simultáneamente e imprime los resultados en un diseño de dos columnas (BM25 a la izquierda, incrustaciones a la derecha) para que las diferencias sean inmediatamente visibles en la misma posición de clasificación.

def comparar(consulta: str, top_k: int = 3): bm25_results = bm25_search(consulta, top_k) embed_results = embedding_search(consulta, top_k) print(f”\n{‘═’*70}”) print(f” CONSULTA: \”{query}\””) print(f”{‘═’*70}”) print(f”\n {‘BM25 (palabra clave)’:<35} {'Incrustar RAG (semántico)'}") print(f" {'─'*33} {'─'*33}") para rango, (b, e) en enumerate(zip(bm25_results, embed_results), 1): b_preview = b['text'][:55].replace('\n', ' ') e_preview = e['text'][:55].replace('\n', ' ') igual = "⬅ igual" si b['chunk_id'] == mi['chunk_id'] else "" imprimir(f" #{rango} [{b['chunk_id']:02d}]{b['score']:.4f} {b_preview}...") imprimir(f" [{e['chunk_id']:02d}]{e['score']:.4f} {e_preview}.... {mismo}") print()
comparar(“término BM25 frecuencia frecuencia de documento inversa”) comparar(“qué es RAG y por qué reduce las alucinaciones”) comparar(“similitud de coseno entre vectores”)

Consulta el cuaderno completo aquí. Además, no dude en seguirnos en Twitter y no olvide unirse a nuestro SubReddit de más de 120.000 ML y suscribirse a nuestro boletín. ¡Esperar! estas en telegrama? Ahora también puedes unirte a nosotros en Telegram.

Soy graduado en ingeniería civil (2022) de Jamia Millia Islamia, Nueva Delhi, y tengo un gran interés en la ciencia de datos, especialmente las redes neuronales y su aplicación en diversas áreas.