Publicado el 5 min de lectura

Combina resultados RAG con fusión de rangos recíprocos

En esta página

Un asistente de un centro de ayuda busca instrucciones para recuperar una cuenta. La búsqueda por palabras clave encuentra páginas con los términos exactos; la búsqueda vectorial aporta otro orden. ¿Qué documentos deberían entrar en el contexto de la IA? En vez de sumar puntuaciones de relevancia distintas, combina las dos listas ordenadas mediante fusión de rangos recíprocos (RRF).

Este tutorial construye y prueba esa combinación en Python, sin conexión. No ejecuta un buscador ni genera embeddings: las entradas son clasificaciones escritas a mano, no resultados medidos de un modelo. En un flujo RAG, este paso va después de recuperar documentos y antes de preparar el contexto de la respuesta.

Dos listas de documentos, identificadas con símbolos de búsqueda por palabras clave y búsqueda semántica, se unen a través de una lente de cristal en una sola lista ordenada.

1. Prepara listas ordenadas comparables

Usa Python 3.10 o posterior; no hacen falta paquetes, cuentas ni claves API. Nuestra consulta imaginaria es «¿Cómo recupero el acceso a mi cuenta?». Los identificadores corresponden a cuatro fragmentos: reset contiene instrucciones de restablecimiento, password trata la recuperación de contraseñas, billing contiene facturas y security aborda la protección de cuentas.

Ambos sistemas deben utilizar los mismos identificadores estables de fragmento y la misma versión del corpus, y devolver primero los mejores resultados. La introducción a la recuperación con ModernBERT explica la recuperación semántica; aquí solo necesitamos el orden resultante.

No sumes directamente una puntuación de palabras clave y una similitud vectorial. RRF utiliza posiciones, por lo que sus escalas originales no necesitan coincidir. A cambio, descarta las diferencias entre puntuaciones: una ventaja pequeña y una grande se convierten en la misma diferencia de posición, como explica la documentación de OpenSearch.

2. Combina los rangos localmente

Por cada aparición, suma 1 / (rank_constant + rank) al total de ese identificador. Las posiciones empiezan en uno; un identificador ausente no aporta nada. Esta es la regla del artículo original sobre RRF. Utilizamos su constante de 60, sin dar por hecho que sea óptima en todos los casos.

Guarda lo siguiente como rrf_demo.py. Proporciona una lista de listas con identificadores de texto no vacíos. Un identificador puede aparecer en ambas listas, pero nunca dos veces en una misma lista.

from collections import defaultdict
from fractions import Fraction


def rrf(rankings, rank_constant=60, limit=3):
    if type(rank_constant) is not int or rank_constant < 1:
        raise ValueError("rank_constant must be a positive integer")
    if type(limit) is not int or limit < 1:
        raise ValueError("limit must be a positive integer")
    scores = defaultdict(Fraction)
    for ranking in rankings:
        if any(not isinstance(doc_id, str) or not doc_id for doc_id in ranking):
            raise ValueError("IDs must be nonempty strings")
        if len(set(ranking)) != len(ranking):
            raise ValueError("duplicate ID within one ranking")
        for rank, doc_id in enumerate(ranking, start=1):
            scores[doc_id] += Fraction(1, rank_constant + rank)
    return sorted(scores.items(), key=lambda item: (-item[1], item[0]))[:limit]


if __name__ == "__main__":
    keyword = ["reset", "billing", "password"]
    vector = ["password", "reset", "security"]
    for doc_id, score in rrf([keyword, vector]):
        print(f"{doc_id}: {float(score):.6f}")

Fraction mantiene la aritmética exacta en esta pequeña demostración. La ordenación usa puntuación descendente y después identificador ascendente para que los empates exactos sean reproducibles. Convierte las puntuaciones a decimales solo para mostrarlas. Una puntuación de fusión sirve para ordenar; no es la probabilidad de que un documento sea correcto.

3. Comprueba la aritmética y los casos límite

Guarda lo siguiente como test_rrf_demo.py, junto al primer archivo. Comprueba el orden completo de los tres primeros resultados, una puntuación calculada a mano, entradas vacías, empates deterministas y el rechazo de duplicados.

from fractions import Fraction
from rrf_demo import rrf

rankings = [
    ["reset", "billing", "password"],
    ["password", "reset", "security"],
]
fused = rrf(rankings)
assert [doc_id for doc_id, _ in fused] == ["reset", "password", "billing"]
assert fused[0][1] == Fraction(1, 61) + Fraction(1, 62)
assert rrf([[], []]) == []
assert [doc_id for doc_id, _ in rrf([["z"], ["a"]])] == ["a", "z"]
try:
    rrf([["reset", "reset"]])
except ValueError as error:
    assert str(error) == "duplicate ID within one ranking"
else:
    raise AssertionError("duplicate was accepted")
print("PASS: ranking, arithmetic, empty inputs, ties, duplicate guard")

Ejecuta ambos archivos desde ese directorio:

python3 -B rrf_demo.py
python3 -B test_rrf_demo.py

Salida estándar esperada, reproducida en las pruebas locales:

reset: 0.032522
password: 0.032266
billing: 0.016129
PASS: ranking, arithmetic, empty inputs, ties, duplicate guard

Ambos comandos terminan con estado 0 y sin salida de error estándar. reset queda primero porque aporta 1/61 + 1/62; le sigue password con 1/63 + 1/61. Un duplicado dentro de una lista provoca un ValueError, que la prueba captura deliberadamente. Las listas vacías devuelven un resultado vacío, no documentos inventados.

Estas comprobaciones establecen el comportamiento del código, no la calidad de recuperación. Si las etiquetas humanas consideran relevantes reset y password, Recall@2 es 0,5 para palabras clave, 1,0 para vectores y 1,0 para la fusión. Aquí la fusión iguala, pero no supera, la mejor entrada. Usa Recall@k y MRR con consultas reservadas para evaluación antes de cambiar un sistema real.

4. Conecta la combinación con búsquedas reales

Sustituye las listas de ejemplo por identificadores devueltos para la misma consulta y aplica los mismos filtros de acceso y metadatos a ambas búsquedas. Recupera una ventana de candidatos de cada una, combínalas y selecciona después los fragmentos finales del contexto. Nunca utilices la fusión para eludir permisos de las fuentes.

La profundidad de candidatos y el número de resultados finales son controles distintos. La referencia de RRF de Elasticsearch distingue rank_window_size del tamaño final y actualmente usa 60 como constante predeterminada. Nuestro limit solo controla el número final; las listas proporcionadas definen las ventanas de candidatos. La fusión no puede recuperar un fragmento ausente de todas las entradas.

5. Resuelve clasificaciones inesperadas

  • Una fuente aparece dos veces: unifica los identificadores de fragmento entre los sistemas. No combines solo por título: distintos fragmentos pueden compartirlo.
  • Los resultados cambian entre ejecuciones: fija la versión del corpus, el orden de entrada, la profundidad de candidatos y la constante. Este desempate no estabiliza entradas cambiantes.
  • Un fragmento irrelevante queda muy arriba: la coincidencia no demuestra relevancia. Revisa ambas entradas y las etiquetas humanas antes de ajustar parámetros.
  • Un sistema no devuelve nada: este código utiliza la lista restante. Registra la entrada ausente y distingue entre falta de coincidencias y un fallo del servicio.

Conclusión práctica

Usa RRF para combinar evidencia ordenada sin fingir que las puntuaciones de distintos buscadores son comparables. Conserva identificadores estables, prueba la combinación y compara la clasificación fusionada con cada sistema mediante consultas reales etiquetadas antes de enviarla a un asistente de IA.

Artículos relacionados

Últimos artículos