Añadí BM25 a mi servicio RAG y la búsqueda vectorial dejó de perder consultas precisas

Actualizado:
Preguntarle a la IA sobre este artículo
Añadí BM25 a mi servicio RAG y la búsqueda vectorial dejó de perder consultas precisas

La búsqueda vectorial pura pierde términos precisos, precios y números de documentos. Lo arreglé en un día, sin cambiar el LLM, sin GPU, sin nuevas dependencias.

Mi servicio RAG funcionaba. La búsqueda vectorial encontraba fragmentos relevantes, el LLM generaba respuestas en ucraniano. Pero cuando un cliente preguntó "consulta de abogado 500 UAH", la búsqueda vectorial devolvió fragmentos sobre servicios legales en general, ignorando el precio exacto. Y la consulta "Orden №142" encontró todo sobre órdenes, excepto el documento №142.

El problema no estaba en el LLM ni en el modelo de embedding. La búsqueda vectorial pura busca el sentido, pero a veces se necesita el texto. Añadí BM25 junto a la búsqueda vectorial, combiné los resultados a través de RRF, y la calidad de la recuperación mejoró notablemente. En este artículo, explico cómo lo hice en producción con Spring Boot + pgvector, qué errores cometí y qué tener en cuenta antes de la implementación.

⚡ En resumen

  • Problema: la búsqueda vectorial "difumina" términos precisos, precios, códigos, números de documentos
  • Solución: búsqueda híbrida — BM25 (palabras clave) + vectorial (semántica) + RRF (fusión)
  • Stack: Java 21, Spring Boot, PostgreSQL + pgvector, tsvector para BM25
  • Configuración: cambio vector/híbrido a través de propiedades, sin recompilación

📚 Contenido del artículo

🎯 Por qué la búsqueda híbrida: dónde la búsqueda vectorial falla

Estoy construyendo un servicio RAG comercial: los clientes empresariales cargan documentos de la empresa (PDF, DOCX, CSV, FAQ), y sus usuarios hacen preguntas en lenguaje natural y obtienen respuestas del LLM basadas en el contenido cargado. Stack: Java 21, Spring Boot + Spring AI, PostgreSQL con pgvector (índice IVFFlat), Ollama localmente (nomic-embed-text para embeddings, mistral-nemo para chat).

Antes de la búsqueda híbrida, mi búsqueda funcionaba así: la consulta del usuario se convierte en un vector (768 dimensiones a través de nomic-embed-text), pgvector encuentra los fragmentos más cercanos por similitud coseno. Esto capta bien el sentido: la consulta "cómo proteger los datos de la empresa" encontraba fragmentos sobre "seguridad de la información" y "protección de datos personales", incluso si las palabras no coincidían.

Pero noté tres tipos de consultas en las que la búsqueda vectorial fallaba consistentemente:

  • Precios y números exactos: "500 UAH" — el modelo de embedding lo convierte en un vector que describe el "sentido" general del precio, pero la diferencia entre 500 y 550 en el espacio vectorial es mínima
  • Códigos y números de documentos: "Orden №142" — el vector "orden" es similar al vector de cualquier otra orden, el número se pierde
  • Términos específicos: "amortización" — la búsqueda vectorial devolvía algo semánticamente similar ("desgaste de activos fijos"), pero no siempre el fragmento con el término exacto

Este es un problema conocido de la búsqueda vectorial, que describí detalladamente en el artículo sobre Búsqueda Híbrida y Reranking. La solución es añadir búsqueda por palabras clave (BM25), que busca coincidencias exactas de palabras, y combinar los resultados con la búsqueda vectorial.

📌 ¿Qué es BM25 y por qué el algoritmo de 1994 sigue funcionando

BM25 (Best Matching 25) es un algoritmo de clasificación de búsqueda de texto que fue formalizado por Robertson y Walker en 1994. No ha muerto en 30 años, y he aquí por qué.

BM25 evalúa la relevancia de un documento según tres factores:

  • TF (Frecuencia de Término) — cuántas veces aparece una palabra en un fragmento específico. Cuanto más frecuente, más relevante
  • IDF (Frecuencia Inversa de Documento) — cuán rara es una palabra en toda la colección. La palabra "documento" aparece en todas partes, es menos valiosa. La palabra "amortización" es rara, es más importante
  • Longitud del documento — normalización para que los fragmentos cortos y largos estén en igualdad de condiciones

Para mi caso, BM25 es crucial porque los documentos empresariales contienen términos precisos, precios, números, cosas que la búsqueda vectorial "difumina". BM25 encuentra un fragmento con "500 UAH" en milisegundos, sin redes neuronales, sin GPU.

Limitación de BM25: no entiende sinónimos. "Automóvil" ≠ "coche". Si el usuario escribió "cancelar suscripción" y en el documento dice "darse de baja de la tarifa", BM25 no encontrará nada. Por eso se necesita una combinación: la búsqueda vectorial capta el sentido, BM25 capta las palabras exactas.

Tabla: cuándo funciona cada uno

Tipo de consultaBúsqueda VectorialBM25Híbrida
"consulta de abogado 500 UAH"⚠️ encontrará algo legal, ignorará el precio✅ coincidencia exacta✅✅
"cómo proteger los datos de la empresa"✅ semántica❌ sin coincidencias exactas
"Orden №142 sobre despido"⚠️ encontrará órdenes en general✅ "Orden №142"✅✅
"devolución de fondos"✅ semántica✅ coincidencia exacta✅✅ ambas señales

Una comparación detallada de BM25 vs Búsqueda de Vectores Densos con benchmarks está en mi artículo sobre Búsqueda Híbrida, Sección 1.

📌 Preparación de la base de datos: migración, tsvector, índice GIN

Antes de escribir código Java, preparé PostgreSQL. En mi tabla vector_store ya tenía vectores (embeddings) para la búsqueda por similitud coseno. Para BM25 se necesita una estructura adicional: tsvector. Es un tipo integrado de PostgreSQL donde el texto se divide en tokens (lexemas) con posiciones. Sin él, la búsqueda de texto completo a través del operador @@ no funciona.

Analogía: un vector (embedding) es la "comprensión del sentido" del texto. Y tsvector es un índice alfabético en un libro. Para diferentes tipos de búsqueda se necesitan diferentes estructuras de datos.

Migración — dos comandos

-- 1. Añadimos una columna para la búsqueda de texto completo
ALTER TABLE vector_store ADD COLUMN content_tsv tsvector;

-- 2. Creamos un índice GIN para una búsqueda rápida por palabras clave
CREATE INDEX idx_vector_store_content_tsv ON vector_store USING GIN (content_tsv);

El primer comando añade la columna content_tsv. Después de la migración, será NULL para todos los fragmentos existentes, lo cual es normal, lo llenaremos más tarde.

El segundo comando crea un índice GIN (Generalized Inverted Index), un tipo de índice optimizado para la búsqueda de texto completo. Sin él, las consultas BM25 con el operador @@ escanearán todas las filas. Con GIN, la búsqueda es rápida. Es como un índice IVFFlat para vectores, pero GIN es para texto.

Llenado de tsvector para fragmentos existentes

UPDATE vector_store
SET content_tsv = to_tsvector('simple', content)
WHERE content_tsv IS NULL;

⚠️ Piedra en el camino: elección de la configuración de búsqueda de texto

Cuando PostgreSQL convierte texto a tsvector, necesita saber el idioma para eliminar palabras vacías ("y", "o", "en") y reducir las palabras a su forma base (stemming: "trabajadores" → "trabajador").

Para el idioma ucraniano, PostgreSQL no tiene un diccionario integrado. Tenía dos opciones:

  • simple — simplemente divide el texto en palabras, lo convierte a minúsculas. Sin stemming, sin palabras vacías. Fiable: no habrá situaciones en las que PostgreSQL haga stemming incorrectamente de una palabra ucraniana
  • russian — el más cercano de los integrados. El stemming funciona parcialmente para el ucraniano (los idiomas son similares), pero puede hacer stemming incorrectamente de algunas palabras

Elegí simple — menos "inteligente", pero fiable. BM25 con la configuración simple aún encuentra coincidencias exactas de palabras clave, y para la "comprensión del sentido" tengo la búsqueda vectorial.

📌 Implementación de HybridSearchService: dos búsquedas + RRF

Mi HybridSearchService hace tres cosas en el método search():

  1. Búsqueda vectorial — el mismo vectorStore.similaritySearch() a través de pgvector, similitud coseno
  2. Búsqueda BM25 — consulta SQL con el operador @@ en la columna content_tsv
  3. Fusión RRF — combinación de ambas listas de resultados según la fórmula 1/(k + rank)

Búsqueda BM25: consulta SQL

Para BM25, uso plainto_tsquery() — divide automáticamente la consulta del usuario en palabras y las busca mediante AND. Los resultados se clasifican mediante ts_rank() — una función integrada de PostgreSQL que calcula una puntuación similar a BM25.

SELECT id, content, metadata FROM vector_store
WHERE content_tsv @@ plainto_tsquery(CAST(:tsconfig AS regconfig), :question)
ORDER BY ts_rank(content_tsv, plainto_tsquery(CAST(:tsconfig AS regconfig), :question)) DESC
LIMIT :topK

⚠️ Trampa: CAST a regconfig

Mi primer intento sin CAST arrojó BadSqlGrammarException. PostgreSQL no puede convertir automáticamente un parámetro de cadena ? (que llega como String a través de JdbcClient) al tipo regconfig. Se requiere un cast explícito: CAST(:tsconfig AS regconfig). Este mismo error también ocurrió al rellenar content_tsv durante la indexación — tuve que corregirlo en dos lugares.

RRF (Reciprocal Rank Fusion): cómo funciona la fusión

RRF fue propuesto por Cormack, Clarke y Buettcher en 2009 (SIGIR '09) y desde entonces se ha convertido en un estándar para la búsqueda híbrida. La fórmula es:

score(d) = Σ 1 / (k + rank)

donde rank es la posición del documento en cada ranking individual, y k es una constante de suavizado (uso el valor estándar de 60).

Qué hace k: controla cuánto difiere el "primer lugar" del "quinto lugar".

  • k=60 (estándar): 1er lugar = 1/61 = 0.0164, 5to = 1/65 = 0.0154. La diferencia es pequeña — todos los resultados son "casi iguales"
  • k=1 (pequeño): 1er = 1/2 = 0.5, 5to = 1/6 = 0.167. La diferencia es triple — los resultados principales dominan
  • k=200 (grande): casi no hay diferencia — solo importa si el fragmento entró en los resultados

¿Por qué exactamente 60? Este valor proviene de el artículo científico original. Lo utilizan Elasticsearch, Qdrant, Weaviate.

Ejemplo con datos reales

Consulta: "Orden N°142 sobre despido"

La búsqueda vectorial devuelve (por similitud coseno — sobre el significado de "despido"):

  1. Fragmento sobre el procedimiento de despido (rank 1)
  2. Fragmento sobre el contrato de trabajo (rank 2)
  3. Fragmento "Orden N°142" (rank 5)

BM25 devuelve (por coincidencia exacta de las palabras "Orden N°142"):

  1. Fragmento "Orden N°142" (rank 1)
  2. Fragmento "Orden N°155" (rank 2)

Puntuación RRF para el fragmento "Orden N°142":

vector rank=5: 1/(60+5) = 0.0154
BM25 rank=1:   1/(60+1) = 0.0164
total:                     0.0318 ← el más alto entre todos

El fragmento "Orden N°142" gana — está alto en ambos rankings. Sin búsqueda híbrida estaría en la 5ª posición y podría no entrar en el contexto del LLM.

Rellenar tsvector al indexar nuevos documentos

Los nuevos documentos pasan por PgVectorIndexingService. Después de vectorStore.add(documents) (que almacena los embeddings), añadí un UPDATE que rellena content_tsv:

private void updateTsVector(Long docId) {
    jdbcClient.sql(
        "UPDATE vector_store SET content_tsv = to_tsvector(CAST(:tsconfig AS regconfig), content) " +
        "WHERE metadata->>'doc_id' = :docId AND content_tsv IS NULL"
    )
    .param("tsconfig", tsConfig)  // @Value("${app.search.tsconfig:simple}")
    .param("docId", String.valueOf(docId))
    .update();
}

Por qué sin trigger: consideré la opción de un trigger de PostgreSQL, pero un trigger es SQL, no conoce el @Value de Spring. Si el cliente cambia el idioma (por ejemplo, de simple a german para un cliente alemán) — habría que recrear el trigger a través de una migración. Con código Java, todo se gestiona desde application.properties.

📌 Configuración: vector vs híbrido a través de propiedades

No eliminé el antiguo PgVectorSearchService. En su lugar, hice un cambio a través de @ConditionalOnProperty:

# application.properties
app.search.mode=hybrid       # o "vector" para búsqueda vectorial pura
app.search.tsconfig=simple   # idioma para tsvector: simple, russian, german, english...
app.search.rrf-k=60          # constante de suavizado RRF
@ConditionalOnProperty(name = "app.search.mode", havingValue = "vector", matchIfMissing = true)
public class PgVectorSearchService implements SearchService { ... }

@ConditionalOnProperty(name = "app.search.mode", havingValue = "hybrid")
public class HybridSearchService implements SearchService { ... }

¿Por qué dos modos?

Mi servicio atiende a diferentes clientes de negocio, y la búsqueda híbrida no es óptima para todos:

  • La búsqueda híbrida genera más carga en la base de datos — dos consultas en lugar de una (vector + BM25), además, un índice GIN adicional consume RAM. Para algunos clientes con una base de datos de documentos pequeña y consultas simples, esto es excesivo
  • Fallback — si la parte BM25 falla o tsvector no está relleno para algunos fragmentos, se puede volver instantáneamente a la búsqueda vectorial pura a través de una propiedad
  • Pruebas A/B — se puede comparar la calidad de las respuestas entre modos para las mismas consultas

Por defecto, matchIfMissing = true en PgVectorSearchService — si la propiedad no está definida, funciona como antes. Nada se rompe.

Configuración por cliente

Para el cliente ucraniano:

app.search.mode=hybrid
app.search.tsconfig=simple

Para el cliente alemán:

app.search.mode=hybrid
app.search.tsconfig=german

Para el cliente con una base de datos pequeña, donde el modo híbrido es excesivo:

app.search.mode=vector

El idioma para tsvector y tsquery debe coincidir — de lo contrario, la búsqueda no funcionará correctamente. PostgreSQL soporta de fábrica: simple, english, german, french, spanish, russian, italian, dutch, turkish y otros.

⚠️ Trampas que encontré

1. BadSqlGrammarException con plainto_tsquery

Problema: el primer intento de búsqueda BM25 arrojó bad SQL grammar. PostgreSQL no pudo convertir el parámetro de cadena ? al tipo regconfig.

Solución: cast explícito CAST(:tsconfig AS regconfig) en dos lugares — en las partes WHERE y ORDER BY de SQL.

2. El mismo error al indexar nuevos documentos

Problema: corregí HybridSearchService, pero al cargar un nuevo documento — el mismo BadSqlGrammarException en PgVectorIndexingService.updateTsVector().

Solución: añadir CAST también allí. Lección — si se usa to_tsvector() con un parámetro a través de JdbcClient, el CAST es necesario *siempre*.

3. @RequiredArgsConstructor no funciona con @Value

Problema: Lombok @RequiredArgsConstructor genera un constructor solo para campos final. Un campo con @Value no es final, por lo tanto, no entra en el constructor.

Solución: reemplacé @RequiredArgsConstructor por un constructor explícito en las clases donde hay dependencias final y configuración @Value.

4. content_tsv = NULL para documentos existentes

Problema: después de la migración, la nueva columna content_tsv era NULL para todos los fragmentos existentes. BM25 devolvía 0 resultados.

Solución: UPDATE único: UPDATE vector_store SET content_tsv = to_tsvector('simple', content) WHERE content_tsv IS NULL;

5. Umbral de similitud para texto ucraniano

El umbral predeterminado de similitud coseno para la búsqueda vectorial es demasiado alto para el texto ucraniano con nomic-embed-text. Lo reduje a **0.1** — de lo contrario, la búsqueda vectorial devolvía pocos resultados. Más detalles sobre la elección del modelo de embedding y el umbral — en el artículo sobre modelos de embedding.

📊 Resultados: vector=5, bm25=2, merged=5

Registros de mi servicio después de implementar la búsqueda híbrida:

Stream query: '¿Cuánto tiempo tarda un reembolso?', sessionId=3
HybridSearchService: Hybrid search results: 5 chunks (vector=5, bm25=1, merged=5)

Stream query: '¿Cuánto tiempo se tarda en desarrollar una landing page?', sessionId=null
HybridSearchService: Hybrid search results: 5 chunks (vector=5, bm25=2, merged=5)

Stream query: 'Escribe sobre el despliegue local. ¿Cómo funciona?', sessionId=3
HybridSearchService: Hybrid search results: 5 chunks (vector=5, bm25=0, merged=5)

Lo que vemos:

  • "reembolso" — BM25 encontró 1 fragmento con coincidencia exacta de palabras, vector encontró 5 por significado. El fragmento que apareció en ambas clasificaciones obtuvo la puntuación RRF más alta y quedó primero
  • "desarrollo de landing page" — BM25 encontró 2 fragmentos. Hybrid dio 5 resultados — fragmentos de ambas clasificaciones, ordenados por puntuación RRF
  • "despliegue local" — BM25 encontró 0. Esto es normal: la consulta "Escribe sobre el despliegue local. ¿Cómo funciona?" — es semántica, sin términos exactos de los documentos. La búsqueda vectorial se encargó sola

Conclusión principal: la búsqueda híbrida no empeora los resultados cuando BM25 no encuentra nada — simplemente devuelve los resultados vectoriales. Pero cuando BM25 *encuentra* algo — la calidad de la clasificación final mejora, porque RRF potencia los fragmentos que aparecen en ambas búsquedas.

Sobre la fragmentación de documentos — cómo divido PDF, DOCX y CSV en fragmentos para indexar, incluyendo la fragmentación semántica de FAQ — lea en el artículo sobre Estrategias de Fragmentación. Y sobre la elección de modelos Ollama que funcionan localmente con 8 GB de RAM — en el artículo sobre Ollama con 8 GB.

❓ Preguntas Frecuentes (FAQ)

¿Es necesaria la búsqueda híbrida si la base de documentos es pequeña (< 100 documentos)?

No necesariamente. En una base pequeña, la búsqueda vectorial suele ser suficiente — hay menos "ruido" y los fragmentos relevantes llegan a la cima. La búsqueda híbrida se justifica cuando los documentos contienen términos exactos, códigos o precios que la búsqueda vectorial "difumina". Por eso hice el cambio a través de app.search.mode — para clientes pequeños, mantengo vector.

¿Por qué tsvector y no Elasticsearch para BM25?

Ya tengo PostgreSQL — almacena tanto documentos como vectores (pgvector). Añadir Elasticsearch como un servicio separado es una sobrecarga de DevOps, monitoreo, sincronización de datos. El tsvector integrado con índice GIN resuelve la tarea de búsqueda BM25 sin infraestructura adicional. Para una escala de más de 10K documentos con alto QPS, vale la pena considerar Elasticsearch o Qdrant con búsqueda híbrida nativa.

¿Cómo afecta la búsqueda híbrida a la latencia?

Mínimamente. BM25 y la búsqueda vectorial se ejecutan secuencialmente (aún no en paralelo), pero BM25 a través del índice GIN — milisegundos. La fusión RRF — microsegundos (cálculo de rangos). El tiempo principal es la incrustación de la consulta a través de nomic-embed-text y la búsqueda vectorial a través de pgvector. Hybrid añade ~5-15ms al tiempo total de búsqueda.

¿Se puede usar la configuración russian en lugar de simple para el ucraniano?

Se puede — el stemming funciona parcialmente (los idiomas son similares). Pero existe el riesgo de stemming incorrecto para algunas palabras ucranianas. simple es más fiable: simplemente tokeniza sin stemming. Para "entender" las palabras, tengo la búsqueda vectorial — BM25 solo se necesita para coincidencias exactas.

¿Qué hacer si BM25 siempre devuelve 0 resultados?

Comprueba tres cosas: (1) si la columna content_tsv está llena — ejecuta SELECT count(*) FROM vector_store WHERE content_tsv IS NOT NULL; (2) si el tsconfig en to_tsvector() y plainto_tsquery() coincide; (3) si hay coincidencias exactas de las palabras de la consulta en el texto de los fragmentos. Si las consultas son predominantemente semánticas — BM25 devuelve 0, y este es un comportamiento normal.

✅ Conclusiones

  • 🔹 La búsqueda vectorial por sí sola no es suficiente: "difumina" términos exactos, precios, números de documentos. BM25 cubre estas lagunas
  • 🔹 Búsqueda híbrida (BM25 + vector + RRF): dos búsquedas paralelas, fusión a través de la fórmula 1/(k + rank). El fragmento que está alto en ambas clasificaciones gana
  • 🔹 pgvector + tsvector: no se necesita Elasticsearch — PostgreSQL con índice GIN es suficiente para BM25 junto con la búsqueda vectorial
  • 🔹 Configuración por cliente: app.search.mode=hybrid/vector, app.search.tsconfig=simple/german/english — todo a través de propiedades, sin recompilación
  • 🔹 Piedras de tropiezo: CAST(:tsconfig AS regconfig) es obligatorio para JdbcClient, content_tsv debe llenarse para los fragmentos existentes, el umbral de similitud para texto ucraniano — 0.1
  • 🔹 Hybrid no empeora: cuando BM25 no encuentra nada — los resultados = búsqueda vectorial. Cuando encuentra algo — la calidad mejora

Mi idea principal: la búsqueda híbrida es el paso más sencillo y eficaz para mejorar la calidad de un sistema RAG después de la búsqueda vectorial básica. Si sus documentos contienen términos, precios, códigos — el efecto es notable de inmediato. Y si no — hybrid simplemente funciona como búsqueda vectorial, sin romper nada.

📖 Fuentes

📚 Artículos relacionados