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 consulta | Búsqueda Vectorial | BM25 | Hí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():
- Búsqueda vectorial — el mismo
vectorStore.similaritySearch() a través de pgvector, similitud coseno
- Búsqueda BM25 — consulta SQL con el operador
@@ en la columna content_tsv
- 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"):
- Fragmento sobre el procedimiento de despido (rank 1)
- Fragmento sobre el contrato de trabajo (rank 2)
- Fragmento "Orden N°142" (rank 5)
BM25 devuelve (por coincidencia exacta de las palabras "Orden N°142"):
- Fragmento "Orden N°142" (rank 1)
- 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