Inicio / Artículos / Recuperación híbrida RAG con pgvector, BM25 y un reclasificador mediante codificador cruzado

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.

1524 palabras

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:

  1. 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.
  2. 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:

  1. Obtener 10 candidatos de cada recuperador, pgvector y BM25.
  2. Combinarlos, obteniendo hasta 20 fragmentos.
  3. Puntuar cada fragmento en relación con la consulta mediante el reranker.
  4. 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.
  • La cadena de conexión contiene credenciales; cárguela desde la configuración en lugar de codificarla directamente.
  • Si prefiere mantener también la búsqueda por palabras clave dentro de la base de datos, la búsqueda de texto completo integrada en PostgreSQL es una alternativa al índice BM25 interno, con un comportamiento de clasificación diferente.
  • 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:

    • EnsembleRetriever no 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.
  • LangChain ha reorganizado sus paquetes en las diferentes versiones, y clases como 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