Recuperación híbrida RAG con pgvector, BM25 y un reclasificador mediante codificador cruzado
Descubra por qué la búsqueda vectorial pura omite los números de pieza y códigos de error, y cómo combinar pgvector, BM25 y el reordenamiento en LangChain para una recuperación precisa mediante RAG.
Un prototipo de generación mejorada por recuperación de información (RAG) basado en búsquedas vectoriales simples suele impresionar en las demostraciones, pero luego decepciona a los usuarios reales. Si se le pregunta sobre el calendario de mantenimiento de un modelo específico de bomba, devuelve consejos generales sobre bombas; al buscar un código de error exacto, el paso correspondiente para solucionar el problema nunca aparece. Esta guía explica por qué la recuperación densa falla con identificadores exactos y muestra cómo solucionarlo mediante un pipeline híbrido: pgvector para búsquedas semánticas, BM25 para la coincidencia de palabras clave, y un reordenador que decide qué fragmentos llegan realmente al LLM.
Por qué la búsqueda vectorial no encuentra identificadores exactos
Los embeddings son excelentes para capturar el significado. Una consulta por “automobile” aparece cerca de documentos sobre “cars” y “vehicles”, ya que el modelo de embeddings coloca los conceptos relacionados cerca unos de otros en un espacio de alta dimensión. Esa misma característica es también su debilidad: la similitud se refiere a la cercanía semántica, no a secuencias de caracteres exactas.
Cuando un usuario busca un número de pieza como TX-99402 o un código de error como E-404, el embedding de esa cadena puede encontrarse muy cerca de TX-99401 o de textos genéricos de solución de problemas. El sistema de recuperación devuelve documentos que “tratan sobre lo mismo”, en lugar del que contiene el token exacto que ingresó el usuario. En manuales técnicos, catálogos de productos y bases de conocimiento de soporte técnico, donde los identificadores transmiten la mayor parte del significado, este es el modo de fallo más común.
Recuperación híbrida: densa y dispersa en paralelo
La solución consiste en ejecutar dos recuperadores complementarios y combinar los resultados que obtienen:
- Recuperación densa (búsqueda vectorial) captura el contexto y el significado. En lugar de utilizar una base de datos vectorial separada, se pueden almacenar los embeddings en PostgreSQL con la extensión
pgvector. Muchas aplicaciones ya funcionan en Postgres, por lo que agregar una columna vectorial mantiene la estructura ligera y ofrece copias de seguridad, control de acceso y transacciones ya conocidos. - Recuperación dispersa (búsqueda por palabras clave) captura coincidencias exactas, acrónimos y jerga del sector. El algoritmo estándar es BM25, una función de clasificación muy utilizada que califica los documentos según la frecuencia con la que aparecen los términos de la consulta, ponderada por lo raros que son esos términos en el corpus y normalizada según la longitud del documento.
Un modelo mental útil: la búsqueda vectorial encuentra el vecindario adecuado, y BM25 localiza el número exacto de la casa. Se toman los mejores resultados de cada uno y se fusionan. Para saber más sobre en qué casos gana cada método, consulte nuestro artículo sobre búsqueda híbrida para conocimiento técnico.
Por qué los resultados fusionados necesitan un reclasificador
La búsqueda híbrida plantea un problema inmediato: ahora se dispone de dos listas ordenadas cuyas puntuaciones no son comparables. La puntuación de BM25 depende de las frecuencias de los términos y no tiene límite, mientras que la similitud vectorial proviene de la distancia de coseno en una escala completamente diferente. Una puntuación semántica de 0.82 no es ni mejor ni peor que una puntuación de BM25 de 14.5; ordenar la unión según la puntuación bruta carece de sentido.
Un reranker resuelve esto ignorando las puntuaciones originales. Se trata de un modelo separado, generalmente un cross-encoder, que lee la consulta y un documento candidato juntos y genera una única puntuación de relevancia para esa pareja. Al poder ver ambos textos al mismo tiempo, puede evaluar la relevancia con mucha mayor precisión que comparando dos embeddings calculados de forma independiente.
El flujo es el siguiente:
- Obtener 10 candidatos de cada recuperador, pgvector y BM25.
- Combinarlos, obteniendo hasta 20 fragmentos.
- Puntuar cada fragmento en relación con la consulta mediante el reranker.
- Mantener los 3 mejores y pasar solo esos al LLM.
Tener menos fragmentos, pero de mejor calidad, también significa un prompt más corto, menos ruido que el modelo debe ignorar y un costo de tokens menor.
El costo de latencia
Los cross-encoders son costosos. Procesar 20 documentos añade un tiempo considerable a cada solicitud, y en una API de chat en streaming (por ejemplo, una construida con FastAPI) retrasa la llegada del primer token. Mida este paso por separado en su seguimiento de latencia. La mejora en la precisión suele valer la pena, pero ajuste el número de candidatos según su presupuesto; nuestro artículo sobre por qué el reranking debe justificar su latencia analiza más a fondo ese equilibrio.
Implementación del pipeline con LangChain
LangChain proporciona componentes para cada parte, por lo que todo el pipeline cabe en dos funciones cortas de Python. Los ejemplos a continuación son conceptuales; adapte la cadena de conexión, los modelos y las rutas de archivos a su entorno.
Ingestión: dividir en fragmentos, incrustar e indexar dos veces
La función de ingestión carga un archivo de texto, lo divide en trozos de 1,000 caracteres con una superposición de 100 caracteres, y luego indexa esos mismos trozos de dos maneras. Primero, los incrusta con el modelo text-embedding-3-small de OpenAI y los almacena en una colección pgvector mediante PGVector.from_documents. Segundo, aplica un BM25Retriever a los trozos y lo serializa en disco con pickle, ya que BM25 construye su índice en memoria.
import pickle
from langchain_community.document_loaders import TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_postgres.vectorstores import PGVector
from langchain_community.retrievers import BM25Retriever
CONNECTION_STRING = "postgresql+psycopg://user:password@localhost:5432/mydb"
COLLECTION_NAME = "hybrid_docs"
def ingest_documents(file_path: str):
# 1. Load and chunk the document
loader = TextLoader(file_path)
docs = loader.load()
text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=100)
chunks = text_splitter.split_documents(docs)
# 2. Store dense embeddings in pgvector
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
PGVector.from_documents(
embedding=embeddings,
documents=chunks,
collection_name=COLLECTION_NAME,
connection=CONNECTION_STRING,
)
# 3. Fit and save the BM25 sparse retriever
bm25_retriever = BM25Retriever.from_documents(chunks)
with open("bm25_retriever.pkl", "wb") as f:
pickle.dump(bm25_retriever, f)
print(f"Successfully ingested {len(chunks)} chunks.")
# Example usage:
# ingest_documents("technical_manual.txt")
Aspectos a tener en cuenta:
- El índice BM25 es una instantánea. Cuando los documentos cambian, se debe volver a crear y guardar el índice, de lo contrario dejará de estar sincronizado con el almacén de vectores.
- Solo abra los archivos pickle que usted mismo haya creado. Al cargar un archivo pickle se ejecuta código, por lo que un archivo manipulado representa un riesgo para la seguridad.
Recuperación: ensemble y posterior reclasificación
La función de recuperación reconstruye ambos recuperadores y los conecta en cadena. El recuperador pgvector devuelve las 10 coincidencias semánticas principales (k=10), y el recuperador BM25 también está configurado para devolver 10 resultados. Un EnsembleRetriever los fusiona con un peso igual de 0.5 cada uno. Un compresor CohereRerank con top_n=3 envuelve al ensemble dentro de un ContextualCompressionRetriever, de modo que cada consulta pasa por la recuperación, la fusión y la reclasificación en una sola llamada invoke.
import pickle
from langchain_openai import OpenAIEmbeddings
from langchain_postgres.vectorstores import PGVector
from langchain.retrievers import EnsembleRetriever, ContextualCompressionRetriever
from langchain_cohere import CohereRerank
CONNECTION_STRING = "postgresql+psycopg://user:password@localhost:5432/mydb"
COLLECTION_NAME = "hybrid_docs"
def setup_hybrid_retriever():
# 1. Initialize Vector Retriever
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vectorstore = PGVector(
connection=CONNECTION_STRING,
embeddings=embeddings,
collection_name=COLLECTION_NAME,
)
# Fetch top 10 semantic matches
pgvector_retriever = vectorstore.as_retriever(search_kwargs={"k": 10})
# 2. Load Keyword Retriever (BM25)
with open("bm25_retriever.pkl", "rb") as f:
bm25_retriever = pickle.load(f)
# Fetch top 10 exact keyword matches
bm25_retriever.k = 10
# 3. Merge pools with EnsembleRetriever (50/50 weighting)
hybrid_retriever = EnsembleRetriever(
retrievers=[bm25_retriever, pgvector_retriever],
weights=[0.5, 0.5]
)
# 4. Rerank the combined 20 chunks to output the absolute top 3
reranker = CohereRerank(cohere_api_key="YOUR_COHERE_API_KEY", top_n=3)
advanced_retriever = ContextualCompressionRetriever(
base_compressor=reranker,
base_retriever=hybrid_retriever
)
return advanced_retriever
def query_system(query: str):
retriever = setup_hybrid_retriever()
best_docs = retriever.invoke(query)
for i, doc in enumerate(best_docs):
print(f"\n--- Result {i+1} ---")
print(doc.page_content)
# Example usage:
# query_system("What is the warranty period for the TX-99402 sensor?")
Algunos detalles que son fáciles de pasar por alto:
EnsembleRetrieverno agrega las puntuaciones brutas. Fusiona las listas por rango utilizando la Fusión de Rango Recíproco ponderado, lo que evita el desfase de escala mencionado anteriormente. También elimina los duplicados, por lo que el reordenador puede recibir menos de 20 fragmentos cuando ambos recuperadores encuentran el mismo.- Nunca incluya una clave API en el código fuente. Lea la clave de Cohere desde una variable de entorno o un gestor de secretos.
setup_hybrid_retriever()se ejecuta en cada consulta aquí, reconectándose a Postgres y desempaquetando BM25 cada vez. En un servicio real, construya el recuperador una sola vez al iniciar y réutilícelo.
EnsembleRetriever y ContextualCompressionRetriever pueden encontrarse en un paquete diferente en su versión. Verifique la documentación actual de LangChain si ocurre un error al importarlas.Puntos clave
- La búsqueda vectorial pura es deficiente con tokens exactos como números de pieza, códigos SKU y códigos de error; BM25 cubre esa deficiencia.
- pgvector le permite añadir funcionalidades de recuperación densa a una infraestructura PostgreSQL existente sin necesidad de una base de datos vectorial separada.
- Las puntuaciones obtenidas de diferentes recuperadores no son comparables, por lo que conviene fusionarlas según su rango y dejar que un codificador cruzado realice el ordenamiento final.
- El reordenamiento mejora la precisión pero aumenta la latencia; defina cuidadosamente el tamaño del conjunto de candidatos y supervise su evolución.
- Trate el índice BM25 como un artefacto de construcción que debe actualizarse con los datos, y mantenga las credenciales fuera del código.
La recuperación híbrida no garantiza respuestas perfectas, pero elimina la causa más común por la cual los sistemas RAG en producción devuelven un contexto plausible pero incorrecto.
Lecturas relacionadas
- Diseñando una memoria de agente en cuatro capas con LangGraph y Amazon Bedrock — Aprenda a dotar a los agentes LLM de memoria episódica, semántica y procedural funcional en Bedrock y LangGraph, así como a protegerla contra envenenamiento, fugas de PII y problemas de seguridad entre usuarios.
- LangChain 1.x en práctica: Cadenas, RAG, herramientas y agentes localmente — Aprenda a crear cadenas, sistemas de generación reforzada por recuperación de información, herramientas y agentes RAG con LangChain 1.x utilizando una configuración local gratuita de Ollama, sin necesidad de claves API.
- Más allá del Top-K: Límites de relevancia, búsqueda híbrida y reevaluación en RAG — Entienda por qué una base de datos vectorial junto con un LLM no constituye un sistema RAG listo para producción, y cómo el particionamiento en fragmentos, los límites de similitud, la búsqueda híbrida, la reevaluación y la evaluación cierran esa brecha.