← Todo el material
Curso Intermedio 23 de agosto de 2026

RAG — Módulo 6: BM25 y búsqueda léxica — por qué el viejo buscador sigue ganando

Módulo 6 del Curso de RAG: el índice invertido, la escalera de TF-IDF a BM25, la fórmula explicada término por término, saturación con k1 y normalización por longitud con b, el análisis léxico en español que decide todo, y BM25 real con rank_bm25 y Elasticsearch.

☰ Índice del contenido

En el módulo anterior montamos búsqueda vectorial que escala a millones de documentos con latencia de milisegundos. Es tecnología moderna, elegante y cara. Este módulo trata de un algoritmo presentado a mediados de los noventa (TREC-3, 1994) que no usa redes neuronales, no necesita GPU, indexa instantáneamente y le sigue ganando a muchos de esos sistemas vectoriales en las consultas que más importan. No es nostalgia: es que BM25 resuelve un problema que los embeddings no resuelven, y ningún RAG serio funciona bien sin él.

La tesis del módulo

El embedding entiende, pero no recuerda. Sabe que “auto” y “vehículo” significan lo mismo, y no tiene idea de que el usuario escribió exactamente SKU-4471-B. BM25 no entiende nada y recuerda todo. Un RAG que solo busca por vectores está ciego justo donde el usuario es más específico.

El punto de partida incómodo

Un caso real, del tipo que aparece en el primer mes de cualquier RAG corporativo.

El usuario escribe: error ERR_CONN_REFUSED en el módulo de facturación.

La búsqueda vectorial devuelve pasajes sobre errores de conexión en general, sobre configuración de red, sobre timeouts. Todo temáticamente correcto. Ninguno menciona ERR_CONN_REFUSED. El documento que lo explica, que existe y dice literalmente ese código, quedó en la posición 340.

¿Por qué? Porque ERR_CONN_REFUSED se tokeniza en fragmentos que el modelo casi nunca vio, aporta poco a la dirección final del vector, y el vector del pasaje que sí lo contiene está dominado por el resto de sus 300 palabras. El embedding capturó el tema y descartó el identificador. Para el modelo, ERR_CONN_REFUSED y ERR_CONN_TIMEOUT son casi el mismo punto del espacio. Para el usuario son problemas distintos con soluciones distintas.

La familia completa de queries donde esto pasa:

  • Códigos de producto: SKU-4471-B, PN-88213, ABB-M2000
  • Nombres propios y siglas internas: proyecto Atacama, SGD-2, plataforma Yaku
  • Números de norma y regulación: ISO 27001, NCh 2369, DS 594, artículo 162
  • Versiones: Python 3.11, v2.4.1, release 2026-Q1
  • Errores literales: ERR_CONN_REFUSED, NullPointerException, HTTP 429
  • Nombres de campo o función: user_id, calcular_iva(), --dry-run

Todo eso tiene algo en común: son tokens raros cuya identidad exacta es el punto de la consulta. Y para eso hay una herramienta que lleva treinta años siendo buena.

Búsqueda léxica y el índice invertido

La búsqueda léxica es coincidencia de términos: busca documentos que contengan las palabras de la consulta, y los ordena según qué tan informativas son esas palabras. No hay semántica, no hay vectores, no hay modelo. Hay conteo.

La estructura que la hace instantánea es el índice invertido. En vez de guardar “documento → palabras que contiene” (que obligaría a recorrer todos los documentos), guarda lo contrario: “palabra → lista de documentos que la contienen”. Se llama invertido porque invierte la dirección natural del mapeo.

DOCUMENTOS

  d1: "la garantia del equipo dura 24 meses"
  d2: "el equipo SKU-4471-B tiene garantia extendida"
  d3: "el plazo de despacho es de 5 dias"

              │
              ▼ invertir
ÍNDICE INVERTIDO

  ┌──────────────┬──────────────────────────────┐
  │ término      │ postings (doc, frecuencia)   │
  ├──────────────┼──────────────────────────────┤
  │ garantia     │ d1:1, d2:1                   │
  │ equipo       │ d1:1, d2:1                   │
  │ sku-4471-b   │ d2:1                         │
  │ despacho     │ d3:1                         │
  │ plazo        │ d3:1                         │
  └──────────────┴──────────────────────────────┘

  query "sku-4471-b" ──► lee 1 fila ──► d2

Buscar sku-4471-b no recorre el corpus: lee una fila del diccionario y obtiene la lista de documentos. Esa lista se llama posting list, y guarda además cuántas veces aparece el término en cada documento (la frecuencia de término o TF) y, opcionalmente, en qué posiciones — lo que permite buscar frases exactas.

Ese es el motivo por el que un índice léxico es instantáneo de actualizar: agregar un documento es agregar entradas a unas cuantas posting lists. No hay que reconstruir un grafo ni reentrenar centroides como en el módulo anterior.

Pero el índice invertido solo dice quién contiene qué. Falta lo difícil: ordenar.

La escalera hasta BM25

Cada peldaño existe porque el anterior falla de una forma concreta.

Peldaño 1: conteo crudo

Puntúa cada documento por cuántas veces aparecen los términos de la query.

Falla de inmediato. Un documento de 50 páginas menciona “garantía” doce veces por puro volumen y le gana a la cláusula de dos párrafos que responde exactamente la pregunta. Los documentos largos ganan siempre. Y las palabras vacías dominan: “de”, “la”, “el” aparecen en todo y aportan cero.

Peldaño 2: TF-IDF

Introduce dos factores multiplicados.

TF (Term Frequency): cuántas veces aparece el término en este documento. Mide qué tan importante es el término para el documento.

IDF (Inverse Document Frequency): qué tan raro es el término en todo el corpus. Se calcula con el logaritmo del total de documentos dividido por cuántos lo contienen.

La intuición del IDF es todo el aporte de la idea. En un corpus de 10.000 documentos:

  • “de” aparece en 10.000 documentos. Que un documento la tenga no dice absolutamente nada. IDF ≈ 0.
  • “garantía” aparece en 400. Ya discrimina algo. IDF medio.
  • “hipoteca” aparece en 12. Un documento que la contiene es casi seguro relevante para una query sobre hipotecas. IDF alto.
  • SKU-4471-B aparece en 1. Es un identificador perfecto. IDF altísimo.

Ahí está la razón de fondo por la que BM25 gana en los casos del principio: los tokens raros son exactamente los que el IDF premia más, y son exactamente los que el embedding diluye.

TF-IDF sigue teniendo dos defectos. El TF crece lineal: cincuenta menciones puntúan cincuenta veces más que una. Y la normalización por longitud es un parche externo, no parte del modelo.

Peldaño 3: BM25

BM25 (Best Matching 25, de la familia de funciones de ranking Okapi, desarrollada por Robertson y Spärck Jones) es TF-IDF con esos dos defectos resueltos de forma principiada. Es la función de ranking por defecto de Lucene, Elasticsearch, OpenSearch y Solr desde hace una década, y sigue siendo el baseline que los papers de retrieval tienen que superar — y bastantes no lo logran.

La fórmula, término por término

score(q,d) = Σ IDF(qi) ·        f(qi,d) · (k1 + 1)
             i          ───────────────────────────────────
                          f(qi,d) + k1 · (1 - b + b·|d|/avgdl)

Se lee así: el puntaje de un documento d para una consulta q es la suma sobre cada término qi de la consulta de una contribución individual. Cada término aporta por separado y los aportes se suman. No hay interacción entre términos — BM25 no sabe que “banco” significa distinto junto a “sentarse” que junto a “crédito”.

Cada pieza:

SímboloQué esRol
qiUn término de la consultaSe itera sobre todos
IDF(qi)Rareza del término en el corpusPeso: términos raros valen más
f(qi,d)Veces que qi aparece en d (el TF)Evidencia bruta
k1Constante de saturación (1,2-2,0)Cuán rápido se aplana el TF
bConstante de normalización (0-1, típico 0,75)Cuánto castiga el largo
`d`
avgdlLargo promedio de documento del corpusReferencia de “normal”

El numerador f · (k1+1) crece con la frecuencia. El denominador f + k1·(...) también crece con la frecuencia. Que ambos crezcan es lo que produce el comportamiento clave: la razón entre ellos tiende a un techo. Ese techo es la saturación.

Saturación del TF: qué hace k1

La pregunta que responde k1: si una palabra aparece 50 veces en vez de una, ¿el documento es 50 veces más relevante?

Obviamente no. Es más relevante que si apareciera una vez, bastante más. Pero la diferencia entre 20 y 50 apariciones es casi nula: a esa altura ya sabemos que el documento trata del tema. Lo que ganamos por menciones adicionales tiene rendimientos decrecientes.

BM25 modela eso: la contribución del TF satura, se acerca asintóticamente a k1 + 1 y nunca lo pasa.

Con k1 = 1,2 y un documento de largo promedio (así el factor de longitud vale 1):

f (apariciones)Contribución BM25Contribución TF-IDF (lineal)
11,001
21,382
51,775
101,9610
502,1550
∞2,20 (el techo, k1+1)∞

La lectura de esa tabla es la esencia del algoritmo. De 1 a 2 apariciones se gana un 38%. De 10 a 50 — cinco veces más menciones — se gana un 10%. TF-IDF, en la columna de al lado, le habría dado al documento con 50 menciones un puntaje absurdo.

Esto tiene una consecuencia práctica enorme: BM25 es naturalmente resistente al keyword stuffing. Repetir una palabra cien veces no sirve de nada. Y hace que un documento que menciona dos términos de la query una vez cada uno le gane a uno que menciona un solo término veinte veces — que es casi siempre lo correcto.

Qué mueve k1:

  • k1 = 0: el TF se ignora por completo, solo importa si el término aparece o no (búsqueda binaria).
  • k1 = 1,2: default de Lucene. Satura rápido. Bueno para documentos cortos y homogéneos.
  • k1 = 2,0: satura más lento, el TF pesa más. Útil en documentos largos y heterogéneos.

Normalización por longitud: qué hace b

El factor (1 - b + b · |d|/avgdl) en el denominador es el corrector de longitud.

Si el documento es de largo promedio, |d|/avgdl = 1 y todo el paréntesis vale exactamente 1: sin efecto. Si es el doble de largo, el denominador crece y el puntaje baja. Si es la mitad, el denominador se achica y el puntaje sube.

La justificación: un documento largo tiene más oportunidades de contener tus términos por puro azar. Sin corrección, el volumen se confunde con relevancia — el defecto original del peldaño 1.

Qué mueve b:

  • b = 0: normalización desactivada. El largo no importa. Tiene sentido cuando todos los documentos miden casi lo mismo (chunks de tamaño fijo, por ejemplo).
  • b = 0,75: default de Lucene. Buen compromiso.
  • b = 1: normalización completa, castigo máximo al documento largo.

En RAG hay un detalle que casi nadie considera: tú controlas el largo de tus documentos, porque los creaste tú en el módulo de chunking. Si tus chunks son de tamaño uniforme, b importa poco y bajarlo a 0,3-0,5 suele ayudar. Si mezclaste chunks de 100 y de 2000 tokens, b alto es tu defensa.

Y avgdl se calcula sobre el corpus indexado: cambia cuando cambia la mezcla de documentos. Es una de las razones por las que los scores de BM25 no son estables en el tiempo.

El IDF de BM25 y el problema del negativo

BM25 usa una variante probabilística del IDF, distinta de la clásica:

IDF(qi) = ln( (N - n(qi) + 0.5) / (n(qi) + 0.5) )

Donde N es el total de documentos y n(qi) cuántos contienen el término.

Tiene una particularidad: puede dar negativo. Cuando un término aparece en más de la mitad del corpus, el numerador se hace menor que el denominador y el logaritmo cae bajo cero. Un término ultra-frecuente pasaría a restar puntaje — un documento sería penalizado por contener la palabra “de”.

Eso es indeseable y Lucene lo evita usando una variante con un +1 dentro del logaritmo que garantiza resultado positivo:

IDF(qi) = ln( 1 + (N - n(qi) + 0.5) / (n(qi) + 0.5) )

Si implementas BM25 a mano y ves puntajes negativos, esta es la causa. Aplicar un piso en cero o usar la variante de Lucene resuelve.

El análisis léxico: donde se gana o se pierde en español

Aquí está el punto que separa un BM25 que funciona de uno que no, y no tiene nada que ver con k1 ni con b.

BM25 compara tokens. Si garantía y garantia son tokens distintos, no hay fórmula que los una. Si equipos y equipo son tokens distintos, buena parte de tus queries falla. El analizador — la cadena de transformaciones que convierte texto crudo en tokens — decide qué se puede encontrar.

Las etapas:

Tokenización. Partir el texto en unidades. La decisión clave para RAG técnico: qué hacer con guiones, puntos y guiones bajos. Un tokenizador ingenuo convierte SKU-4471-B en sku, 4471, b y destruye justamente lo que querías buscar. Configura el tokenizador para preservar identificadores, o indexa un campo adicional con keyword (sin analizar) para códigos.

Lowercase. Garantía y garantía deben ser el mismo token. Prácticamente siempre se activa.

Asciifolding (quitar acentos). Mapea á→a, ñ→n, ü→u. En español es casi obligatorio: los usuarios escriben sin tildes muchísimas veces. Con una advertencia real: colapsa año y ano, y si y sí. Para la mayoría de los corpus el beneficio supera con creces el ruido.

Stopwords. Palabras vacías que se eliminan del índice: “de”, “la”, “que”, “en”. La justificación clásica es que ocupan espacio y no discriminan. En BM25 moderno esa justificación es débil: el IDF ya les asigna un peso casi nulo automáticamente. Y quitarlas rompe queries donde son esenciales:

  • vitamina D — la “D” puede ser stopword en algunas listas
  • ser o no ser — queda vacío
  • la ley vs ley — pierde matiz
  • US (país) vs us (pronombre en inglés)

Recomendación práctica para RAG: no quitar stopwords, o usar una lista mínima. Deja que el IDF haga su trabajo.

Stemming vs lematización. Ambos reducen variantes a una forma común, con filosofías distintas.

El stemming corta sufijos con reglas. El algoritmo estándar en español es Snowball (spanish en Elasticsearch). corriendo, corrió, corremos → corr. Es rápido y no necesita diccionario. Es tosco: puede juntar palabras que no debería y produce raíces que no son palabras.

La lematización usa un diccionario y análisis morfológico para llegar al lema real: corriendo → correr, mejores → bueno. Es más precisa y bastante más lenta; en Python se hace con spaCy y el modelo es_core_news_sm.

El español tiene una morfología mucho más rica que el inglés — conjugaciones, género, número, diminutivos — así que aquí esto pesa mucho más que en un corpus en inglés. Para la mayoría de los casos, el stemmer Snowball español está bien y es el mejor retorno por esfuerzo. Hay un stemmer light_spanish menos agresivo que suele funcionar mejor en dominios técnicos, donde cortar demasiado junta términos distintos.

Sinónimos. Un mapa explícito de equivalencias aplicado en el índice o en la query: auto, automóvil, vehículo. Es la forma barata de darle a BM25 un poco de la elasticidad semántica del embedding. Útil sobre todo para jerga interna: SGD, sistema de gestión documental.

N-gramas. Indexar fragmentos de caracteres (gara, aran, rant…) permite tolerar errores de tipeo y coincidencias parciales, a costa de un índice mucho más grande y más ruido. Úsalo con criterio, típicamente solo en un campo de autocompletado.

Ojo: el analizador en español importa más que el ajuste de k1 y b. Cambiar el analizador inglés por el español en un corpus en castellano suele mover el recall más que cualquier tuning de parámetros que hagas después. Si vas a invertir una tarde en mejorar tu BM25, inviértela en el análisis léxico, no en las constantes.

BM25 vs búsqueda vectorial

DimensiónBM25Búsqueda vectorial
Términos exactos, códigos, SKUExcelenteMalo
Siglas y nombres propiosExcelenteMalo
Números, versiones, normasExcelenteMuy malo
Paráfrasis y sinónimosMalo (sin diccionario)Excelente
Preguntas en lenguaje naturalRegularExcelente
Query en un idioma, doc en otroNo funcionaExcelente (modelo multilingüe)
Vocabulario distinto al del docMaloBueno
Costo de indexaciónCasi cero, sin GPULlamadas al modelo o GPU
Actualización incrementalInstantáneaReembeber + reconstruir índice
InterpretabilidadTotal: se ve qué término aportóNula: es un número
MemoriaBaja (índice invertido comprimido)Alta (vectores + grafo)
Dependencia de idiomaAlta (necesita analizador correcto)Baja (con modelo multilingüe)
Latencia típicaMuy bajaBaja

La interpretabilidad merece una nota, porque es una ventaja operacional que se subestima. Cuando BM25 devuelve un resultado malo puedes explicar exactamente por qué: qué términos coincidieron, con qué IDF, con qué frecuencia. Puedes arreglarlo — agregar un sinónimo, cambiar el stemmer, ajustar un peso. Cuando el vector devuelve un resultado malo, tienes un número de punto flotante y ninguna explicación. Solo puedes cambiar de modelo y rezar.

Y mira las dos columnas otra vez: cada fila donde uno es malo, el otro es excelente. No compiten. Son complementarios de manera casi perfecta, y esa complementariedad es precisamente lo que explota la búsqueda híbrida del módulo 7.

BM25 en la práctica: prototipo con rank_bm25

Para prototipar y para medir baseline, rank_bm25 es suficiente y son diez líneas. Instalación: pip install rank_bm25.

import re
import unicodedata
from rank_bm25 import BM25Okapi

CORPUS = [
    "La garantía del equipo SKU-4471-B cubre 24 meses desde la compra.",
    "El mantenimiento preventivo del equipo se realiza cada 6 meses.",
    "La garantía extendida agrega 12 meses adicionales de cobertura.",
    "El despacho a regiones demora entre 5 y 8 días hábiles.",
    "Para el error ERR_CONN_REFUSED, revise el firewall del servidor.",
    "Los errores de conexión suelen deberse a problemas de red o DNS.",
]


def quitar_acentos(texto: str) -> str:
    """Asciifolding: 'garantía' y 'garantia' deben ser el mismo token."""
    nfkd = unicodedata.normalize("NFKD", texto)
    return "".join(c for c in nfkd if not unicodedata.combining(c))


def tokenizar(texto: str) -> list[str]:
    """Analizador minimo en español.

    El patron incluye guion bajo y guion medio a proposito:
    sin eso, 'SKU-4471-B' se parte en tres y se pierde el identificador,
    que es justo lo que BM25 hace mejor que el vector.
    """
    texto = quitar_acentos(texto.lower())
    return re.findall(r"[a-z0-9_\-]+", texto)


# El corpus se tokeniza UNA vez y se construye el indice.
# Es instantaneo: no hay modelo, no hay GPU, no hay llamadas de red.
corpus_tokenizado = [tokenizar(doc) for doc in CORPUS]
bm25 = BM25Okapi(corpus_tokenizado, k1=1.5, b=0.75)


def buscar(query: str, k: int = 3):
    """La query pasa por EL MISMO analizador que el corpus.

    Igual que la regla del modulo 4 con los embeddings: si indexas y
    consultas con transformaciones distintas, no encuentras nada.
    """
    scores = bm25.get_scores(tokenizar(query))
    orden = sorted(range(len(scores)), key=lambda i: -scores[i])[:k]
    return [(round(float(scores[i]), 3), CORPUS[i]) for i in orden]


for pregunta in ["ERR_CONN_REFUSED", "SKU-4471-B", "cuánto dura la garantía"]:
    print(f"\n=== {pregunta}")
    for score, doc in buscar(pregunta):
        print(f"  {score:6.3f}  {doc[:50]}")

Salida (aproximada, los valores dependen del corpus):

=== ERR_CONN_REFUSED
   2.114  Para el error ERR_CONN_REFUSED, revise el firewall
   0.000  La garantía del equipo SKU-4471-B cubre 24 meses d
   0.000  El mantenimiento preventivo del equipo se realiza

=== SKU-4471-B
   1.902  La garantía del equipo SKU-4471-B cubre 24 meses d
   0.000  El mantenimiento preventivo del equipo se realiza
   0.000  La garantía extendida agrega 12 meses adicionales

=== cuánto dura la garantía
   1.043  La garantía del equipo SKU-4471-B cubre 24 meses d
   0.876  La garantía extendida agrega 12 meses adicionales
   0.000  El mantenimiento preventivo del equipo se realiza

Dos cosas para leer ahí. Primero: ERR_CONN_REFUSED puntúa 2,1 en el documento correcto y cero en el de “errores de conexión” — que es el que la búsqueda vectorial habría puesto primero. Segundo: los ceros. BM25 no devuelve una lista ordenada de todo el corpus como el vector; devuelve lo que coincide y el resto es cero. Esa dureza es virtud (precisión) y defecto (si el usuario no usa las palabras del documento, no hay nada).

Y el detalle crítico para el módulo siguiente: 2,114 y 1,043 no son comparables entre sí. Son scores de queries distintas, en escalas distintas, sin techo definido.

BM25 en producción: Elasticsearch / OpenSearch

BM25 es la similaridad por defecto de Elasticsearch desde la versión 5.0 (antes era TF-IDF clásico). Si ya usas Elasticsearch, ya estás usando BM25 — probablemente con el analizador equivocado.

PUT /chunks
{
  "settings": {
    "index": {
      "similarity": {
        "bm25_chunks": {
          "type": "BM25",
          "k1": 1.2,
          "b": 0.5
        }
      }
    },
    "analysis": {
      "filter": {
        "stemmer_es": {
          "type": "stemmer",
          "language": "light_spanish"
        }
      },
      "analyzer": {
        "es_rag": {
          "type": "custom",
          "tokenizer": "standard",
          "filter": ["lowercase", "asciifolding", "stemmer_es"]
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "texto": {
        "type": "text",
        "analyzer": "es_rag",
        "similarity": "bm25_chunks"
      },
      "codigos": {
        "type": "keyword"
      },
      "tenant_id": {
        "type": "keyword"
      },
      "fuente": {
        "type": "keyword"
      }
    }
  }
}

Las decisiones de esa configuración:

  • b: 0.5 en vez del default 0,75, porque los chunks tienen largo relativamente uniforme y no hace falta castigar tanto por longitud.
  • light_spanish en vez de spanish, porque el stemmer agresivo junta términos técnicos que deberían quedar separados.
  • Sin stopwords, por lo que vimos: el IDF ya se encarga.
  • codigos como keyword, no text: los campos keyword no pasan por el analizador y se guardan literales. Es la forma correcta de garantizar que SKU-4471-B sea buscable exacto, pase lo que pase con el tokenizador.

Otras opciones según tu stack:

Postgres con tsvector / ts_rank. Postgres trae full-text search nativo con soporte de español (to_tsvector('spanish', texto)). Es útil y está ahí gratis, pero ts_rank no es BM25: es un ranking más pobre, sin saturación de TF ni normalización comparable, y su IDF es rudimentario. Sirve como filtro léxico, no como ranker de calidad.

ParadeDB / pg_search. Extensión de Postgres que trae BM25 real (implementado sobre Tantivy) con índices propios. Es la opción interesante si quieres híbrido dentro de Postgres sin sumar Elasticsearch: pgvector para lo denso, pg_search para lo léxico, una sola base de datos.

Qdrant y Weaviate. Ambos traen búsqueda léxica integrada — Qdrant vía sparse vectors con puntajes tipo BM25, Weaviate con BM25 nativo — precisamente para armar híbrido sin operar dos motores.

BM25F. Variante que pondera campos: permite decir que una coincidencia en el título vale tres veces más que en el cuerpo. Es útil en RAG cuando tus chunks arrastran el título de sección, que suele ser una señal fuerte de tema. En Elasticsearch se aproxima con multi_match y boosts por campo ("fields": ["titulo^3", "texto"]).

Ojo: BM25 no es “lo viejo”. Es el baseline que sigue ganándole a muchos sistemas vectoriales caros, es gratis, se actualiza al instante, no necesita GPU y se puede explicar línea por línea. Si tu RAG anda mal, mídelo contra BM25 solo antes de comprar una base de datos vectorial. Es sorprendentemente frecuente descubrir que el sistema con embeddings caros rinde peor que veinte líneas de rank_bm25, y esa medición cuesta una tarde.

Errores comunes

  • Usar el analizador inglés con un corpus en español. Es el error más común y el más caro. garantías y garantía quedan como tokens distintos, los acentos no se normalizan y el stemmer no entiende las conjugaciones. Pierdes más recall aquí que en cualquier otro ajuste del sistema.
  • Quitar stopwords sin pensarlo. El IDF ya las neutraliza. Eliminarlas rompe queries donde son parte del contenido: vitamina D, ser o no ser, artículo 1 de la ley. En RAG, la lista mínima o ninguna.
  • Comparar scores de BM25 entre queries distintas. Un 8,4 en una consulta y un 2,1 en otra no significan nada comparados. BM25 no tiene techo ni escala fija: depende del IDF de los términos, del largo de la query y de avgdl. Esto es crítico para el módulo 7: por eso el híbrido no puede sumar el score de BM25 con el del coseno directamente, y por eso existe RRF.
  • Aplicar analizadores distintos al indexar y al consultar. Si indexas con stemming y consultas sin él, los tokens no calzan y el índice no encuentra nada. Es el equivalente léxico de indexar y consultar con modelos de embedding distintos.
  • Subir k1 para arreglar la relevancia. Cuando los resultados son malos casi nunca es la constante de saturación: es que el tokenizador destrozó los códigos, que falta un sinónimo o que el stemmer es el equivocado. Tunear k1 y b es lo último que se hace, no lo primero, y su ganancia es marginal frente a arreglar el análisis léxico.
  • Tokenizar identificadores con el tokenizador por defecto. SKU-4471-B convertido en sku, 4471, b elimina la ventaja principal de BM25. Preserva los guiones o indexa un campo keyword paralelo para códigos.

La frase para llevarse

“El embedding entiende pero no recuerda; BM25 no entiende nada y recuerda todo. Un buen RAG no elige: usa los dos.”

Qué viene

Tenemos dos buscadores con fortalezas exactamente opuestas y un problema concreto: sus puntajes viven en escalas incomparables — el coseno acotado entre -1 y 1, BM25 sin techo y variable por consulta. Sumarlos es un error matemático. En el módulo 7 vamos a combinarlos bien: Reciprocal Rank Fusion, que fusiona por posición en vez de por puntaje, normalización de scores cuando sí conviene, cómo elegir los pesos de cada rama y cómo medir si el híbrido realmente mejoró o solo te dio más confianza.

Material relacionado

RAGBM25Búsqueda léxicaElasticsearchTF-IDF

Preguntas sobre este curso

Regístrate gratis para hacer preguntas y guardar tu progreso.

✉ Entérate de lo nuevo

Recibe un aviso cuando publique nuevos cursos, workshops o investigaciones. Sin spam.