Depuración de la recuperación RAG: Recall@K, BM25, fusión híbrida y reclasificación
Un pequeño experimento con un pipeline RAG que clasificó al fragmento correcto en cuarto lugar, mostrando cómo el fragmentado, BM25, la fusión de rangos y el reclasificado afectan a Recall@3 y MRR.
Fuentes: How to Fix a RAG System That Keeps Retrieving the Wrong Context
Una pregunta que el recuperador respondió mal
Utiliza un pipeline RAG sencillo y pregúntale algo que debería ser trivial: ¿cuánto tiempo permanece válido un enlace de exportación en formato CSV? La documentación contiene la respuesta: un enlace de descarga de CSV terminado sigue activo durante siete días. Sin embargo, el recuperador clasifica ese pasaje en cuarto lugar, y como solo los tres primeros fragmentos llegan al modelo de lenguaje, este nunca recibe la pregunta.
Lo irritante de este error es que los tres resultados principales no parecen incorrectos. Uno describe cómo se procesan las exportaciones en CSV, otro trata las exportaciones de gran tamaño y otro explica cómo cancelar una exportación. A primera vista parece que el recuperador está funcionando bien. Pero no es así; encontró fragmentos sobre el tema correcto, pero ninguno contenía la información que necesitaba la pregunta.
La reacción habitual ante una mala respuesta del RAG es buscar soluciones en el proceso posterior: reescribir la instrucción, añadir una indicación adicional o culpar al modelo. Ajustar la instrucción resulta satisfactorio porque tres líneas modificadas ya cambian el resultado de inmediato. En este caso, no habría servido de nada. El modelo no seleccionó esos documentos, lo hizo el sistema de recuperación, y ninguna instrucción puede recuperar un pasaje que nunca llegó a la ventana de contexto.
Esto cobra aún más importancia a medida que el sistema madura. Pasada la fase de demostración, una mala recuperación se convierte en un fallo difícil de detectar a partir de la respuesta final. El modelo puede parecer seguro de sí mismo, el contexto puede parecer vagamente relevante, pero la verdadera causa del problema se encuentra varios pasos antes. Una mejor práctica es separar las causas del fallo: falta de evidencias, mala división en fragmentos, clasificación deficiente o problemas en la fase de generación. El orden de depuración pasa entonces a ser:
Respuesta incorrecta: primero inspeccionar la recuperación, luego medirla, después corregirla, y solo después analizar la generación.
Un pequeño experimento muestra cuán importantes son las etapas iniciales.
Un corpus lo suficientemente pequeño como para leerse
El experimento utilizó un conjunto de documentación ficticio sobre límites de velocidad, copias de seguridad, exportaciones en CSV, errores de API, autenticación, monitoreo y registros de auditoría. No era impresionante, y eso era intencional: lo suficientemente pequeño como para que una persona pudiera leer cada fragmento recuperado, pero lo suficientemente complejo como para que varios fragmentos pudieran parecer relevantes para una misma pregunta. Cada fragmento recibió un ID estable:
chunks = [
{
"id": "audit_03",
"source": "audit-logs",
"text": (
"Enterprise audit logs are retained for 365 days "
"before automatic deletion."
),
},
{
"id": "errors_07",
"source": "api-errors",
"text": (
"NX-204 means the requested resource exists but is not "
"available in the caller's current region."
),
},
{
"id": "exports_01",
"source": "csv-exports",
"text": (
"CSV exports run asynchronously and appear in the exports "
"panel when processing completes."
),
},
{
"id": "exports_04",
"source": "csv-exports",
"text": (
"A completed CSV download link remains active for seven days."
),
},
]
El ID parece estar relacionado con la contabilidad, pero evita un atajo problemático más adelante. El conjunto también incluía 12 consultas de evaluación, cada una etiquetada con los fragmentos que eran realmente relevantes:
eval_cases = [
{
"query": "How long are enterprise audit logs kept?",
"relevant": {"audit_03"},
},
{
"query": "What does error NX-204 mean?",
"relevant": {"errors_07"},
},
{
"query": "How long is a CSV export link usable?",
"relevant": {"exports_04"},
},
]
Doce consultas serían un punto de referencia de rendimiento ridículo, pero ese no era el objetivo. El propósito era contar con suficientes casos para detectar cuándo arreglar una consulta de forma silenciosa dañaba a otra. Incluso un conjunto de evaluación muy pequeño socava la confianza que se obtiene al volver a probar las mismas tres preguntas a mano.
Un punto de referencia deliberadamente sencillo
El punto de referencia utilizó TF-IDF, SVD truncado y similitud coseno. Un proyecto real probablemente elegiría un modelo de embedding moderno, pero añadir uno aquí introduciría otra variable sin ayudar en la tarea en cuestión. El sistema de recuperación no necesitaba ser sofisticado; solo tenía que ser algo que pudiera dañarse, inspeccionarse y medirse.
import numpy as np
from sklearn.decomposition import TruncatedSVD
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
from sklearn.preprocessing import normalize
class LsaRetriever:
def __init__(self, chunks, dims=16):
self.chunks = chunks
self.tfidf = TfidfVectorizer(
stop_words="english",
ngram_range=(1, 2),
sublinear_tf=True,
)
term_matrix = self.tfidf.fit_transform(
chunk["text"] for chunk in chunks
)
# The corpus is tiny. SVD doesn't need dimensions it cannot use.
dims = min(
dims,
term_matrix.shape[0] - 1,
term_matrix.shape[1] - 1,
)
if dims < 1:
raise ValueError("Need more text to build the LSA index.")
self.svd = TruncatedSVD(
n_components=dims,
random_state=0,
)
self.index = normalize(
self.svd.fit_transform(term_matrix)
)
def search(self, query, limit=None):
query_vec = self.tfidf.transform([query])
query_vec = normalize(self.svd.transform(query_vec))
similarity = cosine_similarity(
query_vec,
self.index,
)[0]
ranked = np.argsort(similarity)[::-1]
if limit is not None:
ranked = ranked[:limit]
return [
(self.chunks[i], float(similarity[i]))
for i in ranked
]
La primera pasada de fragmentación dividió la documentación frase por frase. Al hacer la pregunta sobre el CSV se obtuvo lo siguiente:
1. 0.879 exports_01
CSV exports run asynchronously and appear in the exports panel...
2. 0.843 exports_02
Large exports are split into multiple compressed files.
3. 0.830 exports_03
Users can cancel an export while it is still queued...
4. 0.772 exports_04
A completed CSV download link remains active for seven days.
El puntaje más alto de 0.879 resulta casi gracioso al mirarlo con perspectiva, ya que parece que el sistema sabe exactamente lo que hace. Pero luego se lee el fragmento obtenido y no responde a nada. Ese es el momento de dejar de considerar el puntaje de similitud como una garantía.
El fallo ocurre antes de la generación: el recuperador elige un contexto plausible pero inútil, y el LLM solo ve lo que sobrevive al umbral top-k.
Imprimir lo que se obtuvo
Antes de modificar modelos, tamaños de fragmentos o estrategias de recuperación, un pequeño ayudante que imprime lo que devuelve el recuperador resulta muy útil.
def show_hits(retriever, query, limit=5):
print(f"\n{query}\n")
for position, (chunk, score) in enumerate(
retriever.search(query, limit),
start=1,
):
print(
f"{position:>2}. {score:.3f} "
f"{chunk['id']} ({chunk['source']})"
)
print(f" {chunk['text']}\n")
De todo lo que hay en el experimento, este herramienta es la que se utiliza con más frecuencia. Una vez que los fragmentos aparecen en la pantalla, los fallos se clasifican en diferentes tipos. Puede darse el caso de que esté presente el documento correcto pero se seleccione la sección equivocada, o bien que dos o tres fragmentos sean copias casi idénticas. En otros casos, la consulta y el pasaje comparten suficiente vocabulario como para obtener una buena puntuación, aunque el pasaje sea inútil como evidencia. Los números simples ocultan cada uno de estos casos a menos que alguien lea el texto.
También destaca algo que es fácil pasar por alto: el recuperador toma una decisión de gran importancia. Todo lo que sobrevive a esta etapa se convierte en la versión de la realidad del modelo, y eso merece un análisis más detallado del que suele recibirse.
Evaluación por ID, no por coincidencia de cadenas
Un borrador inicial de evaluación incluía un atajo que posteriormente se eliminó. Este atajo verificaba la presencia de una cadena de indicación en el texto del fragmento:
if answer_hint in chunk["text"]:
...
Práctico, pero poco fiable. Un pequeño cambio en los límites de los fragmentos, una modificación en la puntuación o una parafrasisación pueden hacer que una recuperación correcta parezca incorrecta. Peor aún, vincula la evaluación a la redacción de una respuesta sintética y no al juicio de relevancia que realmente importa. Por eso los fragmentos reciben IDs de antemano, para que el evaluador pueda verificarlos directamente:
def evaluate_retriever(retriever, cases, k=3):
recall_scores = []
reciprocal_ranks = []
for case in cases:
hits = retriever.search(case["query"])
relevant = case["relevant"]
relevant_positions = [
position
for position, (chunk, _) in enumerate(hits, start=1)
if chunk["id"] in relevant
]
found_in_top_k = sum(
position <= k
for position in relevant_positions
)
recall_scores.append(
found_in_top_k / len(relevant)
)
reciprocal_ranks.append(
1 / relevant_positions[0]
if relevant_positions
else 0.0
)
return {
f"recall@{k}": float(np.mean(recall_scores)),
"mrr": float(np.mean(reciprocal_ranks)),
}
Se monitorearon dos métricas. Recall@K indica si la evidencia necesaria llega a la ventana de contexto, mientras que Mean Reciprocal Rank distingue entre un fragmento correcto en la posición uno y uno oculto en la posición ocho. Resultados de referencia: Recall@1 de 0.750, Recall@3 de 0.917 y MRR de 0.854.
El valor preocupante es Recall@3. Con un contexto planificado de tres fragmentos, algunas preguntas ya perdían la información necesaria para obtener una respuesta correcta incluso antes de que comenzara la generación. Ajustar el prompt del sistema en ese momento sería inútil.
Tamaño del fragmento: un culpable parcial
Los fragmentos a nivel de oración eran los principales sospechosos, y en parte eran responsables del problema. Con fragmentos de dos oraciones, Recall@1 aumentó de 0.750 a 0.917, Recall@3 alcanzó 1.000 y MRR subió a 0.958. Los fragmentos de tres oraciones dieron resultados casi idénticos. Las páginas completas obtuvieron puntajes perfectos, lo cual resulta genial durante unos cinco segundos.
El problema era que las páginas eran muy pequeñas. Devolver una página completa hacía casi imposible pasar por alto la respuesta, por lo que la recuperación mejoró en parte porque cada unidad contenía más del corpus. Pero eso tiene un costo: los fragmentos más grandes consumen contexto, incluyen oraciones no relacionadas y dejan menos espacio para pruebas separadas. Además, un sistema de recuperación que siempre devolviera un manual completo también obtendría excelentes resultados en la capacidad de recordación.
Así que la lección no era un tamaño mágico para los fragmentos. Era que la división original a nivel de oración era demasiado agresiva, lo cual es una conclusión menos brillante pero útil. También genera sospechas sobre parámetros como estos:
chunk_size = 500
chunk_overlap = 50
No hay nada malo con esos números en sí mismos. Simplemente no deberían llegar a la fase de producción porque, por casualidad, un tutorial los utilizó. El fragmentado forma parte del proceso de recuperación de información, y al igual que todo lo demás, necesita evidencias.
Los términos exactos transmiten el significado
El ejemplo en CSV ofrecía otra pista: cada pasaje de alto ranking era semánticamente similar a los datos exportados, por lo que el sistema de recuperación no había interpretado mal el dominio. Simplemente no distinguía lo suficiente entre los pasajes que contenía. La documentación técnica mejora esto, ya que las cadenas de texto exactas transmiten una enorme cantidad de significado. Un código de error como NX-204 no es meramente semánticamente similar a un código vecino, y lo mismo ocurre con customer_id, un punto de acceso, un número de versión o el nombre de una función.
BM25 obtuvo un puntaje uniforme de 1.000 en Recall@1, Recall@3 y MRR. Superó al modelo de referencia semántico, tuvo un rendimiento mejor del que cualquiera hubiera imaginado y, lo que es irritante, fue impecable en este pequeño conjunto de datos. Sería tentador titular algo como “Los embeddings están muertos”, pero los datos no lo respaldan. Lo que demuestra es que este conjunto de evaluación fue de gran utilidad para la terminología exacta. Un conjunto lleno de preguntas reformuladas podría elegir a un ganador diferente. La conclusión es cuán importante fue la coincidencia léxica en este corpus, y esa es precisamente la razón de crear un conjunto de evaluación.
Recuperación híbrida con fusión de rangos
Descartar la recuperación semántica aún parecía prematuro. Los dos métodos fallan de manera diferente, por lo que sus clasificaciones se combinaron mediante Fusión de Rangos Recíprocos. Un detalle importante en su implementación es que cada recuperador contribuye solo con un número limitado de candidatos. Clasificar un corpus enorme dos veces y fusionar todo en Python sería un diseño extraño de replicar.
from collections import defaultdict
def fuse_rankings(vector_hits, bm25_hits, rrf_k=60):
fused = defaultdict(float)
chunks_by_id = {}
for hits in (vector_hits, bm25_hits):
for rank, (chunk, _) in enumerate(hits, start=1):
chunk_id = chunk["id"]
chunks_by_id[chunk_id] = chunk
fused[chunk_id] += 1 / (rrf_k + rank)
ranked_ids = sorted(
fused,
key=fused.get,
reverse=True,
)
return [
(chunks_by_id[chunk_id], fused[chunk_id])
for chunk_id in ranked_ids
]
El contenedor que lo rodea es sencillo:
def hybrid_search(query, lsa, bm25, candidate_k=20):
vector_hits = lsa.search(query, limit=candidate_k)
bm25_hits = bm25.search(query, limit=candidate_k)
return fuse_rankings(vector_hits, bm25_hits)
La recuperación híbrida elevó el MRR a 0.958 y corrigió el error original en Recall@3. Aún así perdió frente a BM25. El experimento no se modificó hasta que la arquitectura más elaborada ganó, ya que eso anularía el propósito del mismo. Tener más componentes no implica automáticamente mayor relevancia.
Un reordenador económico en la capa superior
La recuperación debe buscar de forma amplia, por lo que tiene sentido que sea económica. Una vez que hay 10 o 20 candidatos plausibles, se vuelve viable realizar una verificación de relevancia más específica. En este experimento local, dicha verificación consistió en un evaluador de n-gramas de caracteres. Se trata de un reordenador experimental económico y no de una recomendación para uso en producción. Un sistema real sería el lugar adecuado para considerar el uso de un codificador cruzado aprendido.
def cheap_local_rerank(query, candidates, limit=5):
"""
Good enough for this experiment.
I'd use a learned reranker for a real deployment.
"""
candidate_text = [
chunk["text"]
for chunk, _ in candidates
]
tfidf = TfidfVectorizer(
analyzer="char_wb",
ngram_range=(3, 5),
min_df=1,
)
matrix = tfidf.fit_transform(
[query, *candidate_text]
)
relevance = cosine_similarity(
matrix[0],
matrix[1:],
)[0]
reranked = sorted(
zip(candidates, relevance),
key=lambda row: row[1],
reverse=True,
)
return [
(chunk, float(score))
for ((chunk, _), score) in reranked[:limit]
]
El pipeline termina con una forma sencilla de dos etapas.
El proceso consiste primero en obtener un conjunto amplio de candidatos y luego reordenarlos para que solo los fragmentos más relevantes lleguen al LLM. Después del reordenamiento, el pequeño conjunto de prueba obtuvo una puntuación perfecta de 1.000 en Recall@1, Recall@3 y MRR. Esa cifra no debería aparecer en una diapositiva que afirme que el problema de recuperación de información ya está resuelto, ya que doce consultas solo ayudan al desarrollo pero demuestran poco sobre la fiabilidad general. Aun así, el experimento cumplió su propósito. Así es como se compara cada etapa:
Si la historia hubiera sido redactada antes del experimento, cada fila habría mejorado de manera ordenada. Pero no fue así. BM25, la opción sencilla, empató con el pipeline final. La búsqueda híbrida mejoró con respecto a la línea base semántica inicial sin alcanzar el primer lugar. Los bloques más grandes resolvieron un problema, pero generaron otra duda sobre el presupuesto de contexto. La falta de orden hace que los resultados sean más creíbles, y reveló algo concreto sobre esta tarea: la terminología exacta era muy importante. Eso es mejor que ganar un concurso de popularidad de arquitecturas.
La pregunta original era:
How long is a CSV export link usable?
Antes de los cambios, el ranking era el siguiente:
1. CSV exports run asynchronously...
2. Large exports are split...
3. Users can cancel an export...
4. A completed CSV download link remains active for seven days.
Después:
1. A completed CSV download link remains active for seven days.
2. Users can cancel an export while it is still queued...
3. CSV exports run asynchronously...
Los documentos de origen permanecieron igual que la pregunta, por lo que no se necesitaba un LLM más inteligente. Lo que cambió fue la evidencia.
Un orden de depuración para respuestas erróneas de RAG
Esa diferencia determina cómo reaccionar cuando una respuesta de RAG falla. Cuando la evidencia necesaria nunca llega al contexto, hay que buscar en la recuperación de información. Cuando la evidencia está claramente presente y el modelo sigue hablando tonterías, solo entonces es necesario analizar la generación, los estímulos, el orden del contexto y el comportamiento del modelo. También existe un tercer tipo de fallo: la respuesta correcta sí entra en el contexto pero queda oculta bajo un montón de texto poco relacionado. Eso es un problema de clasificación o de construcción de contexto, y clasificarlo todo como “alucinación” lo oculta.
El flujo práctico primero verifica las pruebas obtenidas y luego decide si el problema radica en la recuperación o en la generación. El punto de decisión es la parte importante. Cuando el rendimiento de Recall@3 es deficiente, no tiene sentido pasar una hora reformulando “la respuesta solo usando el contexto proporcionado” de seis maneras cada vez más estrictas, ya que el contexto ya está incorrecto.
Los cambios en los prompts siguen pareciendo más rápidos: editas, vuelves a ejecutar y ves una nueva respuesta de inmediato. El trabajo de recuperación es más lento y menos satisfactorio: leer fragmentos, etiquetar su relevancia, verificar las clasificaciones, lidiar con los límites de los fragmentos y, a veces, descubrir que un recuperador léxico tradicional supera a una configuración más sofisticada. Sin embargo, lo que enseña es más fiable.
La pregunta que más ayuda no es por qué el LLM respondió incorrectamente, sino si el LLM llegó a obtener la información que necesitaba. Parece algo demasiado obvio como para preocuparse por ello, pero a menudo se pasa por alto.