Inicio / Artículos

Más allá de la similitud Top-K: BM25, búsqueda híbrida, reclasificación y MMR en RAG

Más allá de la similitud top-K en RAG: BM25 para términos exactos, búsqueda híbrida con RRF, reclasificación mediante codificador cruzado y MMR para mayor variedad, combinados en un único pipeline.

Editor: REACTAPP.TOP7 min de lectura1698 palabras

Fuentes: Retrieval Strategies in RAG: Beyond Basic Similarity Search

El primer sistema RAG que construyen la mayoría de los desarrolladores sigue una fórmula: incrustar los documentos, colocarlos en una base de datos vectorial y, cuando llega una pregunta, obtener los fragmentos más cercanos y entregárselos al LLM. Funciona, hasta que deja de hacerlo. Los usuarios comienzan a quejarse de que se pasan por alto respuestas obvias. El documento contiene claramente la información, pero la recuperación no la muestra, o el resultado principal parece relacionado con la pregunta en apariencia pero no es lo que el usuario necesitaba. La búsqueda por similitud simple es un buen punto de partida con limitaciones reales, y una vez que estas se hacen evidentes, estrategias de recuperación mejores las solucionan. A continuación se presentan las razones por las que falla, qué hacen la búsqueda híbrida y el reclasificado al respecto, y cómo combinar todos los elementos en un único flujo de trabajo.

Búsqueda por similitud, brevemente

En una configuración básica, la pregunta del usuario se convierte en un embedding, es decir, una lista de números que representan su significado. Cada bloque en el almacén de vectores también tiene uno. El sistema encuentra los bloques cuyos embeddings están más cerca de los de la pregunta, medidos mediante similitud de coseno o producto escalar, y devuelve los K mejores.

# Basic similarity search — what most RAG systems do
results = vector_store.similarity_search(query, k=3)

Es adecuado para una coincidencia semántica general que funciona bien. Una pregunta sobre cómo funciona la fotosíntesis mostrará fragmentos sobre cómo las plantas convierten la luz solar en energía, incluso si la redacción es diferente. Pero tiene puntos ciegos.

Dónde falla

Se pasan por alto las palabras clave exactas. La búsqueda semántica captura el significado en lugar de las palabras literales, lo cual suele ser una ventaja. Sin embargo, cuando alguien busca un término específico, como el nombre de un producto, un acrónimo o una persona, el modelo de incrustación podría no coincidir lo suficientemente bien. Supongamos que el usuario pregunta sobre la arquitectura del modelo BERT. Existe un fragmento que indica que BERT utiliza transformadores bidireccionales, pero un fragmento más amplio sobre las arquitecturas de modelos de lenguaje podría tener mayor prioridad porque abarca un rango semántico más amplio.

Una puntuación alta no equivale a la mejor respuesta. La similitud mide cuán relacionados están dos textos, no cuán útil es uno para responder al otro. Un fragmento que dice que los sistemas RAG son útiles para responder preguntas está muy cercano a la consulta “¿qué es RAG?”, mientras que el fragmento que contiene la definición real es más útil.

Los resultados principales también pueden ser redundantes. Los documentos reales se repiten en diferentes fragmentos, por lo que los tres primeros resultados pueden decir más o menos lo mismo, desperdiciando espacio en la ventana de contexto con material duplicado.

BM25 para la recuperación de palabras clave

BM25 es un algoritmo clásico de recuperación de información, similar al que se utiliza en los motores de búsqueda tradicionales. Compara documentos según palabras clave exactas, asignando un peso a cada una según su rareza en todo el corpus, y no utiliza embeddings, solo texto en bruto. Una búsqueda de “índice FAISS” devuelve fragmentos que contienen literalmente ambas palabras, y la palabra rara tiene más peso que la común.

from langchain_community.retrievers import BM25Retriever
from langchain_community.document_loaders import TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
# Load and split documents
loader = TextLoader("knowledge_base.txt")
documents = loader.load()
splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50)
chunks = splitter.split_documents(documents)
# Create BM25 retriever
bm25_retriever = BM25Retriever.from_documents(chunks)
bm25_retriever.k = 3
# Search
results = bm25_retriever.invoke("FAISS vector index")
for doc in results:
    print(doc.page_content[:200])

Brilla con nombres específicos de productos, personas o términos técnicos, con consultas cortas y precisas, y en cualquier situación en la que la redacción exacta sea más importante que el significado. Tiene dificultades cuando el usuario expresa las cosas de manera diferente al documento (usando sinónimos o paráfrasis), con consultas largas de tipo conversacional, y cuando el significado prevalece sobre las palabras literales.

Búsqueda híbrida

La búsqueda híbrida ejecuta de forma simultánea la recuperación semántica y BM25. La búsqueda semántica se ocupa del significado y los sinónimos, mientras que BM25 gestiona las palabras clave exactas; así, juntas cubren casos que cada una omitiría por separado. Las dos listas de resultados se fusionan mediante Reciprocal Rank Fusion (RRF), una técnica que combina listas ordenadas en una sola sin necesidad de que sus puntuaciones compartan la misma escala.

from langchain_community.retrievers import BM25Retriever
from langchain_google_genai import GoogleGenerativeAIEmbeddings
from langchain_community.vectorstores import FAISS
from langchain.retrievers import EnsembleRetriever
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import TextLoader
#Load and split
loader = TextLoader("knowledge_base.txt")
documents = loader.load()
splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50)
chunks = splitter.split_documents(documents)
#Retriever 1 — Semantic (FAISS)
embeddings = GoogleGenerativeAIEmbeddings(
    model="models/embedding-001",
    google_api_key="YOUR_KEY"
)
vector_store = FAISS.from_documents(chunks, embeddings)
semantic_retriever = vector_store.as_retriever(search_kwargs={"k": 5})
#Retriever 2 — Keyword (BM25)
bm25_retriever = BM25Retriever.from_documents(chunks)
bm25_retriever.k = 5
#Combine both — Hybrid Search
hybrid_retriever = EnsembleRetriever(
    retrievers=[semantic_retriever, bm25_retriever],
    weights=[0.6, 0.4]  # 60% semantic, 40% keyword
)
#Search
results = hybrid_retriever.invoke("What is FAISS and how does it store vectors?")
print(f"Retrieved {len(results)} chunks")
for i, doc in enumerate(results):
    print(f"\nChunk {i+1}: {doc.page_content[:150]}")

El parámetro weights determina cuánto contribuye cada recuperador. Inclinarse ligeramente hacia la búsqueda semántica es adecuado para la mayoría de los casos, aunque el equilibrio adecuado depende de sus datos. Elija el enfoque híbrido cuando los documentos contengan términos técnicos, nombres de productos o nombres propios, cuando los usuarios combinen preguntas conversacionales con preguntas basadas en palabras clave, o cuando desee utilizar la coincidencia de palabras clave como medida de seguridad adicional tras la comprensión semántica.

Reclasificación

La reclasificación funciona en dos etapas. Primero, se realizan búsquedas amplias para obtener los 20 o 30 mejores resultados con el recuperador, lo cual es económico y rápido. Luego, esos candidatos se pasan por un modelo de reclasificación especializado que evalúa cada uno según su capacidad para responder a la pregunta, manteniendo solo los 3 o 5 mejores.

La lógica es que la similitud vectorial es una aproximación rápida de la relevancia, mientras que el reordenamiento es más lento pero mucho más preciso. Limitarse primero con la similitud y luego realizar el reordenamiento permite obtener tanto velocidad como precisión.

from langchain_community.cross_encoders import HuggingFaceCrossEncoder
from langchain.retrievers.document_compressors import CrossEncoderReranker
from langchain.retrievers import ContextualCompressionRetriever
#Base retriever — fetch top 20 candidates
base_retriever = vector_store.as_retriever(search_kwargs={"k": 20})
#Reranker model
reranker_model = HuggingFaceCrossEncoder(
    model_name="BAAI/bge-reranker-base"
)
#Reranker compressor — keeps only top 3 after reranking
reranker = CrossEncoderReranker(model=reranker_model, top_n=3)
#Combine base retriever + reranker
reranking_retriever = ContextualCompressionRetriever(
    base_compressor=reranker,
    base_retriever=base_retriever
)
#Search — fetches 20, reranks, returns top 3
results = reranking_retriever.invoke("How does FAISS perform similarity search?")
print(f"Final chunks after reranking: {len(results)}")
for i, doc in enumerate(results):
    print(f"\nTop {i+1}: {doc.page_content[:200]}")

Un reclasificador con codificador cruzado lee la pregunta y un fragmento de texto juntos como una pareja y determina cuán relevante es ese fragmento específico para esa pregunta concreta. La similitud vectorial analiza cada texto de forma independiente y nunca considera directamente su relación. Utilice el reclasificación cuando la precisión sea importante y las respuestas incorrectas tengan consecuencias graves, cuando esté realizando respuestas a preguntas a partir de documentos específicos, o siempre que la calidad de la respuesta afecte directamente a los usuarios.

Relevancia Marginal Máxima

MMR aborda el problema de la redundancia. En lugar de tomar simplemente los fragmentos más cercanos, elige aquellos que son a la vez relevantes y variados, de modo que la misma información no se devuelva varias veces.

# Standard similarity search — might return redundant chunks
standard_results = vector_store.similarity_search(query, k=3)
# MMR search — returns relevant AND diverse chunks
mmr_results = vector_store.max_marginal_relevance_search(
    query,
    k=3,           # final number of chunks to return
    fetch_k=20,    # candidates to consider before selecting diverse ones
    lambda_mult=0.5  # 0 = max diversity, 1 = max relevance
)

El parámetro lambda_mult establece el equilibrio entre relevancia y diversidad. Un valor de 1.0 significa relevancia pura, idéntico a una búsqueda de similitud normal. Un valor de 0.0 significa diversidad pura, con fragmentos lo más diferentes entre sí posible. Un valor de 0.5 equilibra ambos aspectos y suele ser una buena configuración inicial. El MMR es útil cuando los documentos son repetitivos, cuando se desea abordar varios aspectos de un tema o cuando la ventana de contexto es demasiado estrecha para fragmentos duplicados.

Un pipeline completo

Al combinarlos, la recuperación híbrida extiende la búsqueda, el reordenamiento selecciona los fragmentos más relevantes y una cadena RAG completa genera la respuesta.

from langchain_google_genai import ChatGoogleGenerativeAI, GoogleGenerativeAIEmbeddings
from langchain_community.vectorstores import FAISS
from langchain_community.retrievers import BM25Retriever
from langchain_community.cross_encoders import HuggingFaceCrossEncoder
from langchain.retrievers import EnsembleRetriever, ContextualCompressionRetriever
from langchain.retrievers.document_compressors import CrossEncoderReranker
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import TextLoader
# Setup
llm = ChatGoogleGenerativeAI(model="gemini-1.5-flash", google_api_key="YOUR_KEY")
embeddings = GoogleGenerativeAIEmbeddings(model="models/embedding-001", google_api_key="YOUR_KEY")
# Load and split documents
loader = TextLoader("your_knowledge_base.txt")
documents = loader.load()
splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50)
chunks = splitter.split_documents(documents)
# Build retrievers
vector_store = FAISS.from_documents(chunks, embeddings)
semantic_retriever = vector_store.as_retriever(search_kwargs={"k": 10})
bm25_retriever = BM25Retriever.from_documents(chunks)
bm25_retriever.k = 10
# Hybrid retriever
hybrid_retriever = EnsembleRetriever(
    retrievers=[semantic_retriever, bm25_retriever],
    weights=[0.6, 0.4]
)
# Add reranking on top
reranker = CrossEncoderReranker(
    model=HuggingFaceCrossEncoder(model_name="BAAI/bge-reranker-base"),
    top_n=3
)
final_retriever = ContextualCompressionRetriever(
    base_compressor=reranker,
    base_retriever=hybrid_retriever
)
# RAG prompt
prompt = ChatPromptTemplate.from_messages([
    ("system", """Answer the question using only the context below.
    If the answer is not in the context, say "I don't have that information."

    Context: {context}"""),
    ("human", "{question}")
])
def format_docs(docs):
    return "\n\n".join(doc.page_content for doc in docs)
# Complete chain
rag_chain = (
    {"context": final_retriever | format_docs, "question": RunnablePassthrough()}
    | prompt
    | llm
    | StrOutputParser()
)
# Use it
answer = rag_chain.invoke("Your question here")
print(answer)

La calidad de la recuperación resulta claramente mejor que la búsqueda de similitud básica por sí sola.

Elegir una estrategia

Una breve guía de decisiones relaciona las situaciones con las estrategias correspondientes:

Just starting out / simple use case?
    → Basic similarity search is fine
Documents have specific technical terms or product names?
    → Add BM25 → use Hybrid Search
Answer quality is critical, wrong answers are costly?
    → Add Reranking on top of any retriever
Documents have lots of repeated content?
    → Use MMR instead of standard similarity search
Production system with high quality requirements?
    → Hybrid Search + Reranking together

No sobrediseñes desde el primer día. Comienza con búsquedas básicas de similitud, agrega una búsqueda híbrida una vez que observes fallos en la detección de palabras clave, e incorpora el reclasificado cuando sea necesario mejorar la calidad de las respuestas. Cada adición implica un costo en términos de complejidad, latencia y recursos de cómputo. Para aplicaciones RAG en producción, la búsqueda híbrida combinada con reclasificado es el estándar a seguir, y la complejidad solo debería introducirse cuando un problema específico lo exija.

Una observación práctica sobre el reclasificado: se necesita ejecutar pip install sentence-transformers, y el modelo BAAI/bge-reranker-base se descarga automáticamente la primera vez que se utiliza, ocupando aproximadamente 1 GB. Para entornos de producción, las APIs de reclasificado alojadas en Cohere o Jina AI evitan la carga de ejecutar el modelo localmente.

Leer más

  • Más allá de la búsqueda por similitud: filtrado de metadatos para RAG con S3 Vectors — De dónde provienen los valores de filtrado, cuándo vale la pena usar un clasificador y cómo configurar metadatos filtrables y no filtrables en Amazon S3 Vectors.