Inicio / Artículos

RAG para tablero de dardos: Qué hacer cuando Top-K devuelve el mismo fragmento tres veces

La recuperación simple top-k puede devolver fragmentos casi idénticos. Dartboard RAG selecciona tanto la relevancia como la diversidad; aquí está el algoritmo, el código y cuándo utilizarlo.

Editor: REACTAPP.TOP11 min de lectura2616 palabras

Fuentes: Dartboard RAG: When Top-K Returns Three Versions of the Same Chunk

La recuperación simple top-k falla de forma silenciosa. Cada vez que un corpus dice lo mismo en varios lugares, los k fragmentos más cercanos a una consulta pueden resultar ser un único fragmento que lo expresa tres veces con palabras diferentes.

Imagínese un informe de 100 páginas sobre el cambio climático en el que cinco secciones tratan cada una de los gases de efecto invernadero, presentados de manera ligeramente distinta pero que transmiten el mismo mensaje. Alguien pregunta “¿Qué causa el cambio climático?”, y el índice FAISS devuelve lo siguiente:

Top 3:
1. "Greenhouse gases trap heat in the atmosphere, causing warming..."  (sim 0.91)
2. "Atmospheric greenhouse gases are the primary driver of climate change..."  (sim 0.89)
3. "The trapping of heat by greenhouse gases leads to rising temperatures..."  (sim 0.88)

Eso son tres fragmentos que en realidad representan un solo hecho. El usuario preguntó por las causas, en plural, y recibió una sola causa repetida. Los fragmentos sobre deforestación, agricultura industrial y cambio en el uso del suelo estaban en las posiciones 8, 12 y 17, por lo que ninguno llegó al contexto del modelo de lenguaje. La respuesta del modelo refleja su entrada: un párrafo fluido sobre los gases de efecto invernadero sin mención alguna a las otras causas.

La búsqueda vectorial no es la culpable. Hizo lo que se le indicó, devolviendo los resultados más similares según el criterio top-k. El problema es que la similitud no tiene en cuenta la redundancia. Tres fragmentos pueden estar todos al máximo grado de cercanía a la consulta y, además, ser copias casi idénticas entre sí; por lo tanto, un método simple de top-k devolverá a los tres mientras descarta material diferente y relevante.

Dartboard RAG modifica la regla de selección. Mientras que la recuperación estándar busca los k fragmentos más cercanos a la consulta, este enfoque busca k fragmentos que sean cada uno altamente relevantes y también estén bien separados entre sí. De esta manera se abarcan las partes relevantes del corpus y no varias copias de su mejor coincidencia.

Imagínese un tablero de dardos

Considere la consulta como el centro del tablero y cada fragmento como un dardo ya clavado en él. Cuanto más cerca esté del centro, más relevante será el fragmento.

Chunks plotted by relevance to query
                    (closer to center = higher cosine sim)

                       ●●●         ← cluster of near-duplicate chunks
                      ●●●          (all about "greenhouse gases")
                      ● bull's-eye = QUERY

                                ●     ← chunk about deforestation
                                       (relevant but different topic)

                    ●           ●     ← chunks about agriculture, ocean carbon

                       ●  ●          ← chunks about historical climate


   STANDARD TOP-3 picks:           DARTBOARD TOP-3 picks:
   3 closest darts                 1 closest, then darts that are also
   = 3 darts in the same           good but spread across the board
     spot near bull's-eye          = better coverage of relevant content

La recuperación ordinaria recoge las tres saetas más cercanas. El método del tablero de dardos recoge tres saetas que están cerca pero también distribuidas en diferentes posiciones.

El proceso completo

Arquitectónicamente se trata de una recuperación estándar a la que se le añade un mecanismo de selección más inteligente. El almacén de vectores y las representaciones vectoriales permanecen exactamente como estaban. La única novedad es un paso de selección agresiva que decide cuáles de los 15 candidatos pasarán a los 5 finales.

Paso 1: obtener más de lo necesario

El algoritmo no puede elegir entre candidatos que no tiene, por lo que necesita un conjunto más grande que el k final. La implementación de referencia multiplica la cantidad por un factor de sobremuestreo de 3:

fetch_k = self.k * self.oversampling     # default: 5 × 3 = 15
candidates = vector_store.search(query_embedding, k=fetch_k)

Por lo tanto, se seleccionan 15 candidatos en lugar de 5. FAISS los devuelve ordenados según la similitud coseno, lo que coloca al candidato 0 más cerca de la consulta y al candidato 14 más lejos. Luego, el paso de selección elige el mejor subconjunto de cinco ponderando tanto la relevancia como la diversidad.

El siguiente paso necesita el embedding de cada candidato y no solo su texto, por lo que se obtiene el conjunto completo como objetos Document con los embeddings adjuntos.

Paso 2: crear dos matrices de distancia

A partir de los embeddings de los candidatos, se calculan dos matrices:

# Normalize all vectors so dot product = cosine similarity
query_norm = query_vec / np.linalg.norm(query_vec)
cand_norm = candidate_matrix / np.linalg.norm(candidate_matrix, axis=1, keepdims=True)

# Distance = 1 - cosine_similarity
query_distances = 1.0 - np.dot(query_norm, cand_norm.T)        # (1, N)
document_distances = 1.0 - np.dot(cand_norm, cand_norm.T)      # (N, N)
  • query_distances contiene la distancia desde la consulta hasta cada candidato. Un valor bajo indica alta relevancia. Su forma es (1, N).
  • document_distances almacena la distancia entre pares de cada uno de los candidatos. Un valor bajo significa que esos candidatos se parecen entre sí. Su forma es (N, N).

La relevancia proviene de la primera matriz y la diversidad de la segunda.

Paso 3: convertir las distancias en probabilidades logarítmicas

Las distancias brutas no se combinan bien. La diferencia entre 0.3 y 0.4 puede parecer significativa, pero la simple resta da como resultado 0.1, subestimándola. Por eso el algoritmo mapea las distancias a probabilidades logarítmicas bajo un kernel gaussiano:

def lognorm(dist, sigma):
    return -np.log(sigma) - 0.5 * np.log(2 * np.pi) - dist**2 / (2 * sigma**2)

Ese es el registro de una densidad gaussiana. En términos intuitivos, una distancia pequeña genera una alta probabilidad (lo cercano implica que es probablemente relevante o similar), mientras que una distancia grande produce una probabilidad baja; además, sigma determina cuán afilada es la curva. Un valor pequeño de sigma, con un valor por defecto de 0.1, genera picos estrechos que reaccionan intensamente ante pequeñas diferencias de distancia. Un valor mayor, como 0.5, suaviza la curva para que las distancias similares se traten de manera más similar.

Trabajar en espacio logarítmico es importante para la estabilidad numérica. Con distancias pequeñas y un valor bajo de sigma, las probabilidades brutas se reducen a números flotantes extremadamente pequeños que terminan siendo cero, mientras que las probabilidades en escala logarítmica permanecen dentro de límites definidos y comparables.

Paso 4: el bucle codicioso

Aquí es donde realmente se aplica la lógica del tablero de dardos. El procedimiento es el siguiente:

  1. Tomar primero el fragmento más relevante. Todavía no se aplica ninguna prueba de diversidad, ya que no se ha seleccionado nada con lo que compararlo.
  2. Mantener max_distances, que registra para cada candidato su mayor distancia de probabilidad logarítmica con respecto a cualquier documento ya seleccionado. Un valor más alto indica una mayor diferencia con respecto a la selección actual.
  3. Para cada posición restante, calificar a cada candidato no seleccionado combinando un término de relevancia (qué tan cercano está a la consulta, proveniente de query_probs) con un término de diversidad (qué tan lejos está del candidato ya seleccionado más cercano, proveniente de max_distances). La combinación es diversity_weight × diversity + relevance_weight × relevance.
  4. Agregar las puntuaciones combinadas mediante logsumexp, método que es numéricamente estable.
  5. Elegir el candidato con la puntuación total más alta, añadirlo y actualizar max_distances.
  • Repetir hasta que se seleccionen k candidatos.
  • En código:

    # Step 1: pick most relevant first
    most_relevant_idx = np.argmax(query_probs)
    selected_indices = [most_relevant_idx]
    max_distances = doc_probs[most_relevant_idx].copy()    # diversity tracker
    
    # Step 2-6: iteratively add diverse + relevant chunks
    while len(selected_indices) < num_results:
        # For each candidate, compute "diversity from any selected"
        updated_distances = np.maximum(max_distances, doc_probs)
    
        # Combine relevance + diversity
        combined = (diversity_weight * updated_distances
                    + relevance_weight * query_probs[np.newaxis, :])
    
        # Aggregate per candidate (logsumexp for numerical stability)
        normalized = logsumexp(combined, axis=1)
    
        # Mask already-selected
        for idx in selected_indices:
            normalized[idx] = -np.inf
    
        # Pick the best
        best_idx = np.argmax(normalized)
        max_distances = updated_distances[best_idx]
        selected_indices.append(best_idx)
    

    La idea central es la puntuación combinada. Cada selección evalúa si algo es relevante frente a si difiere de lo que ya se tiene. La primera selección maximiza la relevancia pura, mientras que cada selección posterior equilibra ambos aspectos.

    Qué hace la matemática, de forma informal

    En cada paso, el bucle pregunta en esencia: de los candidatos aún no seleccionados, ¿cuál mejora mejor tanto su puntuación de relevancia como la cobertura de la región relevante?

    Imagínese un diagrama de dispersión 2D donde la relevancia se representa en el eje x y la distancia desde el conjunto seleccionado en el eje y. La primera selección corresponde al punto más a la derecha. Después, cada otro candidato tiene un valor en y que representa su distancia desde esa primera selección.

    Para la segunda selección, se desea algo que esté muy a la derecha (relevante) y también en una posición alta (a diferencia de la primera). Al tener ambos pesos en 1.0, el equilibrio es igual, por lo que el bucle tomará un fragmento que es un poco menos relevante pero considerablemente más diferente.

    Relevance →
                Low              ●◯
    
                       ●         ●           ←  Standard top-k picks these 3
                                                  (highest relevance, regardless of diversity)
                        ●        ●
                           ●●●●●●  ●  ●  ●     ← Many similar high-relevance chunks
                High         (cluster)
    
    
                Diversity ↓
                from
                selected
                            ↓
                         ↓     ↓   ←  Dartboard picks 1 from cluster,
                                     then far-away ones with high relevance still
    

    El método top-k simple toma los puntos más a la derecha sin importar cuán juntos estén. Dartboard toma primero el punto más a la derecha y luego se expande intencionadamente hacia otras regiones relevantes.

    Las dos ponderaciones

    relevance_weight = 1.0     # how much we care about chunks being close to query
    diversity_weight = 1.0     # how much we care about chunks being different from each other
    

    Tanto uno como el otro tienen un valor por defecto de 1.0, lo que significa una importancia igual. Al modificarlos se cambia su comportamiento: aumentar la diversidad si siguen apareciendo duplicados, y aumentar la relevancia si se introducen fragmentos fuera de tema. Los valores por defecto son adecuados para la mayoría de los corpus.

    Un ejemplo práctico con un corpus rico en duplicados

    La implementación de referencia cuenta con un parámetro duplicate_factor que repite cada fragmento N veces, una forma útil de probar la diversidad en un corpus sintéticamente denso. Establézcalo en 5 y pregunte “¿Qué causa el cambio climático?”

    La recuperación estándar de los 5 mejores resultados da lo siguiente:

    1. "Greenhouse gases cause warming..."  (sim 0.91)
    2. "Greenhouse gases cause warming..."  (sim 0.91)  ← DUPLICATE
    3. "Greenhouse gases cause warming..."  (sim 0.91)  ← DUPLICATE
    4. "Greenhouse gases cause warming..."  (sim 0.91)  ← DUPLICATE
    5. "Greenhouse gases cause warming..."  (sim 0.91)  ← DUPLICATE
    
    Unique results: 1/5
    

    Los cinco resultados forman literalmente un único bloque, todos igualmente cercanos a la consulta; por lo tanto, el LLM recibe un mismo hecho reexpresado cinco veces.

    Dartboard top-5 muestra lo siguiente:

    1. "Greenhouse gases cause warming..."  (highest relevance — wins first pick)
    2. "Deforestation reduces the carbon sink..."  (different chunk, still relevant)
    3. "Industrial agriculture emits methane..."  (third unique cause)
    4. "Land-use changes alter surface albedo..."  (fourth unique cause)
    5. "Fossil fuel combustion is the largest CO₂ source..."  (related to #1 but different angle)
    
    Unique results: 5/5
    

    Una vez que se elige el primer fragmento, el término de diversidad penaliza cada copia de él, ya que cada una tiene diversidad cero en relación con la selección. Por lo tanto, el bucle busca contenido distinto en los espacios del dos al cinco, y el LLM recibe cinco hechos diferentes sobre las causas. Ese es el mejor caso posible. Los corpus reales rara vez contienen duplicados exactos, por lo que el efecto es más leve, aunque la misma dinámica se aplica a los casi duplicados.

    Todo en unas veinte líneas

    def dartboard_select(query_emb, candidate_embs, k=5, sigma=0.1):
        # 1. Compute distance matrices
        query_dist = 1 - cosine(query_emb, candidate_embs)        # query→each
        doc_dist   = 1 - cosine(candidate_embs, candidate_embs)   # each→each
    
        # 2. Convert distances to log-probabilities
        query_probs = lognorm(query_dist, sigma)
        doc_probs   = lognorm(doc_dist, sigma)
    
        # 3. Pick most relevant first
        selected = [np.argmax(query_probs)]
        max_distances = doc_probs[selected[0]].copy()
    
        # 4. Iteratively add diverse + relevant
        while len(selected) < k:
            updated = np.maximum(max_distances, doc_probs)
            combined = updated + query_probs[np.newaxis, :]   # equal weights = sum
            scores = logsumexp(combined, axis=1)
            for idx in selected:
                scores[idx] = -np.inf       # don't re-select
    
            best = np.argmax(scores)
            max_distances = updated[best]
            selected.append(best)
    
        return selected
    

    La longitud es moderada. La dificultad radica en el significado de las matemáticas y no en el recuento de líneas. Una vez que la regla queda clara (elegir el fragmento más relevante y luego dar prioridad a aquellos que son relevantes y diferentes de lo ya seleccionado), el código es fácil de seguir.

    Parámetros que vale la pena ajustar

    Hay dos configuraciones que merecen atención.

    • oversampling determina el tamaño del conjunto de candidatos. El valor por defecto de 3 significa 15 candidatos para finalmente quedarse con 5. Aumentarlo a 5 o 10 proporciona al algoritmo más elementos con los que trabajar, pero ralentiza el cálculo de las distancias entre pares. En un corpus con mucha duplicación, un oversampling de 10× permite que dartboard llegue a fragmentos verdaderamente distintos en posiciones más bajas de la clasificación.
    • diversity_weight debe aumentar si sus evaluaciones muestran que aún hay duplicados que se escapan, y disminuir si los resultados están muy dispersos, con fragmentos fuera de tema seleccionados simplemente por ser “diferentes”. El valor predeterminado de 1.0 es equilibrado, y valores de 1.5 o 2.0 son razonables para corpus muy densos.

    sigma es más delicado y rara vez necesita modificarse. Se dice que el valor predeterminado de 0.1 funciona bien para distancias coseno en embeddings de OpenAI. Si pasa a un modelo de embedding con escalas de distancia diferentes, es posible que necesite recalibrarlo, aunque la mayoría de las personas pueden dejarlo sin cambios.

    Dónde ayuda y dónde no

    Dartboard es adecuado para varias situaciones.

    • Corpus con casi duplicados. Muchas fuentes que tratan un mismo tema, documentos de políticas repetitivos, archivos de noticias con cobertura superpuesta y publicaciones en foros indexadas. El método estándar de top-k suele fallar aquí.
    • La cobertura es tan importante como la precisión. Resúmenes de investigación, preguntas y respuestas exhaustivas y análisis comparativos, donde el usuario desea todo el conjunto de información relevante y no solo una parte seleccionada.
    • Recursos computacionales adicionales. El costo extra proviene del cálculo matricial sobre el conjunto de candidatos, no de llamadas adicionales al modelo LLM. Es imperceptible para k=5 con 15 candidatos y se vuelve notable para k=20 con 60 o más.

    Ayuda menos en estos casos.

    • Corpus naturalmente diversos. Si los fragmentos ya son distintos desde el punto de vista temático, ya sea mediante segmentación semántica o documentos bien estructurados, el método top-k devolverá de todos modos fragmentos variados, y Dartboard añade carga adicional sin alterar el ranking.
    • Preguntas muy específicas. “¿Cuál es el punto de ebullición del mercurio?” tiene una respuesta clara. La lógica de diversidad podría elegir fragmentos menos relevantes en aras de la variedad, lo que perjudicaría la calidad de la respuesta.
    • k muy pequeño. Con un valor de k de 1 o 2 hay poco margen para la diversificación, por lo que el método top-k habitual es suficiente.
    • Procesos que ya combinan fusión, reclasificación y enmarcado. Cada uno de estos aborda problemas relacionados a su manera. Añadir Dartboard encima de un proceso ya complejo rara vez ayuda y solo incrementa la complejidad.

    La idea clave

    El aspecto interesante de Dartboard RAG no es la matemática, sino el reconocimiento de que la relevancia por sí sola es un objetivo incorrecto para la recuperación top-k.

    El método estándar top-k pregunta cuáles son los k elementos individualmente más relevantes. Esto trata cada elemento de forma independiente e ignora lo que ya está en el conjunto de resultados, por lo que cuando existen varios elementos igualmente relevantes, se obtienen todos ellos incluso si dicen lo mismo.

    El objetivo más adecuado se basa en el conjunto completo: ¿cuáles son los k elementos que, juntos, cubren mejor lo que necesita la consulta? Se trata de una optimización diferente, en la cual la puntuación del elemento k depende de los elementos del 1 al k-1 y no solo de su propia relevancia. Los métodos ávidos como Dartboard son aproximaciones viables de esta estrategia.

    Este patrón se encuentra también fuera del ámbito de la recuperación de información:

    • Muestreo de datos de entrenamiento. Las técnicas de aprendizaje activo utilizan muestreo consciente de la diversidad por la misma razón.
    • Recomendadores. Proponer tres variantes ligeramente diferentes de un mismo producto no ayuda a nadie, por lo que la diversidad mejora la lista.
    • Búsqueda de imágenes. Diez fotos casi idénticas son peores para el usuario que diez imágenes diferentes y relevantes.
    • Resumen extractivo. Para elegir oraciones se necesita variedad, no solo importancia individual de cada oración.

    La lección general es que, al seleccionar un conjunto de elementos, se deben evaluar los conjuntos en sí y no los artículos individuales. Dartboard es la versión RAG de ese principio, y una vez que lo comprendas verás la misma trampa en muchos otros procesos del tipo “clasificar y luego tomar los k mejores”.

    Hay un costo: cálculos adicionales, y resulta más difícil explicar por qué un bloque supera a otro. La recompensa es una cobertura mejor, lo que a menudo se traduce directamente en respuestas más precisas, especialmente cuando el corpus no está organizado de manera ordenada con un bloque por hecho distinto.