BM25 contra embeddings: le añadí búsqueda semántica a mi buscador (con números)
Le puse un interruptor opcional a Ask que reordena los resultados de BM25 con embeddings de MiniLM. Antes de dejarlo encendido por defecto probé 15 preguntas reales en los dos idiomas: ganó claramente en 1, ayudó un poco en 2, empató en 8 y perdió en 1, y en 3 fallaron los dos. Esta es la cuenta completa, sin redondear para arriba.

Hace un rato conté cómo funciona el buscador de esta web con BM25: contar palabras con criterio, sin ningún modelo de por medio. Al final de ese artículo dejé una pregunta abierta — qué compra exactamente el siguiente escalón, cuando los vectores sustituyen a las palabras contadas — y prometí que el laboratorio la respondía. Esta vez la respondo en el propio buscador: hay un interruptor nuevo, apagado por defecto, que llamo "Semántica", y esto es lo que hace, cuánto cuesta y cuándo de verdad ayuda.
La idea es reordenar, no sustituir. BM25 sigue siendo el primer filtro: para cada pregunta calcula sus 50 fragmentos mejor puntuados de los 876 que tiene el índice ahora mismo, exactamente como antes. Lo nuevo es lo que pasa después, solo si activas el interruptor: la pregunta se convierte en un vector de 384 números con MiniLM (Xenova/all-MiniLM-L6-v2, el mismo modelo que ya usan el mapa de embeddings y el comparador de frases del laboratorio), se compara por coseno contra el vector precalculado de cada uno de esos 50 fragmentos, y la puntuación final es mitad BM25 normalizado, mitad coseno. Se deduplica por página y se aplica el mismo empujón de idioma de siempre — nada de eso cambia.
Los vectores viajan precalculados y cuantizados. Un script nuevo, `build-ask-embeddings.mjs`, corre el mismo modelo en Node sobre los 876 fragmentos del índice y escribe `ask-embeddings.bin`: cada vector se cuantiza a enteros de 8 bits con una escala global (127 entre el valor absoluto máximo de todo el corpus, con un pelín de margen) documentada en una cabecera JSON al principio del propio fichero. En float32 esos vectores pesarían 1,3 MB; cuantizados pesan 329 KB. El navegador los descarga una sola vez, los descuantiza dividiendo por esa misma escala, y a partir de ahí son números normales.
El orden importa más que los ids. El fichero de embeddings no repite ninguna URL ni ningún id: el vector en la posición 407 es, sencillamente, el embedding del fragmento 407 de `ask-index.json`. Es más compacto, pero significa que los dos ficheros tienen que publicarse juntos y en el mismo orden — así que el flujo pasó a ser `build-ask-index.mjs` y luego `build-ask-embeddings.mjs`, en ese orden, y el buscador comprueba en cada carga que el número de vectores coincide con el número de fragmentos del índice. Si algún día se generan por separado y quedan desalineados, el buscador lo nota, avisa por consola y sigue funcionando solo con BM25 en vez de reordenar con vectores que ya no corresponden a los fragmentos correctos.
Antes de dejarlo encendido por defecto, lo medí. Escribí 15 preguntas reales, la mitad con una palabra distinta a la que usa el contenido, un par mezclando inglés técnico en una frase en español, y un par de control donde BM25 ya debería ganar sin ayuda. Para cada una comparé el top-3 de BM25 puro contra el top-3 del híbrido, y juzgué a mano cuál respondía mejor la pregunta. La tabla completa — las 15 preguntas, ambos top-3, y por qué gana cada uno — está en el repositorio; aquí van los números que de verdad importan: 1 victoria clara del híbrido, 2 victorias pequeñas, 8 empates donde BM25 ya bastaba, 1 derrota del híbrido, y 3 preguntas donde ninguno de los dos encontró lo que buscaba.
La victoria clara fue en inglés, no en español. "How do I know if my customers are about to leave" no comparte ni una palabra con el proyecto que debería responderla (`customer-churn-prediction`), y aun así el coseno de MiniLM la sitúa como la mejor coincidencia de las 876 del índice entero. BM25 la tenía en el puesto 9; el híbrido la subió al puesto 2. Pero la misma pregunta en español — "cómo sé qué clientes van a dejar de usar mi servicio" — no tuvo ese final feliz: ni BM25 ni el híbrido la metieron en el top-3. El comparador de frases del laboratorio ya avisa de esto con sus propios ejemplos: este modelo está entrenado sobre todo en inglés, y aquí se nota en un caso real, con una web entera de por medio.
Para saltos de verdad conceptuales, MiniLM tampoco los da. Probé "cómo evito que mi modelo se aprenda los datos de memoria en vez de generalizar", que debería apuntar al artículo sobre fugas de datos entre train y test. Hasta aquí ninguna sorpresa: por palabras, BM25 lo deja en el puesto 8 de 161 candidatos. Lo que no esperaba es que el coseno puro — comparando la pregunta contra los 876 fragmentos enteros, sin la ayuda de BM25 — lo dejara en el puesto 22, peor que las palabras contadas. Con "quiero saber si mi modelo nuevo es mejor de verdad o es solo casualidad", que debería encontrar regresión real o ruido en tu eval, el coseno puro lo manda al puesto 82 de 876. Y con una pregunta sobre un chatbot que no se invente cosas, la palabra "chatbot" tira del vector hacia el artículo sobre transformers en vez de hacia el de RAG verificable, puesto 107 de 876. Ningún truco de mezcla arregla esto: si el vector de la pregunta ya apunta lejos del fragmento correcto, sumarle la mitad de un BM25 débil no lo acerca lo suficiente.
Un fallo más sutil: se puntúa el fragmento, no la página. Una página larga tiene varios fragmentos, y BM25 y el coseno pueden preferir fragmentos distintos de la misma página — la deduplicación por URL llega después de puntuar, así que compite el fragmento que BM25 ya eligió, no el mejor fragmento posible de esa página en ninguna de las dos métricas. Lo vi con números concretos: para la pregunta sobre clientes en español, el fragmento de `customer-churn-prediction` que BM25 trajo al pozo de candidatos tenía un coseno de solo 0,351 contra esa pregunta, aunque otro fragmento de la misma página llega a 0,572. Con la mezcla 50/50 eso da una puntuación híbrida de 0,539, muy por debajo del 0,813 y el 0,776 de sus dos competidores. Un rediseño que puntuara por página — el mejor coseno entre todos sus fragmentos, no solo el que BM25 ya había elegido — probablemente arreglaría parte de esto. No lo construí esta vez: quería medir lo que de verdad estoy publicando, no lo que podría publicar con más tiempo.
La mezcla 50/50 ganó por descarte, no por intuición. Probé también 65% BM25 / 35% coseno y 35% BM25 / 65% coseno sobre las mismas 15 preguntas. La versión cargada hacia BM25 apenas movía nada — la pregunta de los clientes en inglés seguía en el puesto 3 en vez del 1 o 2. La versión cargada hacia el coseno sí que rescataba mejor esos casos, pero a cambio empeoraba controles donde BM25 ya acertaba: en "es mi modelo mejor de verdad o solo casualidad" metía un artículo sobre transformers que no tenía nada que ver, solo por compartir vocabulario vago sobre "modelo" y "entrenar". La mezcla a partes iguales fue la única de las tres que nunca empeoró un caso de control mientras seguía rescatando el caso claro.
Por eso el interruptor sigue apagado por defecto. De 15 preguntas reales, en 8 BM25 ya bastaba sin ayuda — la mayoría de preguntas sobre esta web comparten vocabulario con su respuesta, porque son preguntas sobre términos técnicos concretos (evalgate, tokenmeter, BM25) que aparecen igual en la pregunta y en el texto. La búsqueda semántica cuesta una descarga de modelo de ~20-25 MB la primera vez — lo digo explícitamente en la interfaz antes de que actives nada — y solo se nota cuando la pregunta usa palabras distintas a las del contenido, sobre todo en inglés. Si prefieres verlo en marcha: el botón de interrogante de abajo a la derecha tiene el interruptor "Semántica" en su cabecera; enciéndelo, pregunta algo con tus propias palabras y compara.

