Главная / Статьи

Dartboard RAG: Что делать, когда Top-K возвращает один и тот же фрагмент три раза

Обычный метод поиска top-k может возвращать почти идентичные фрагменты. Dartboard RAG одновременно учитывает релевантность и разнообразие; вот описание алгоритма, код и ситуации, когда его следует использовать.

Издатель: REACTAPP.TOP11 мин чтения2616 слов

Источники: Dartboard RAG: When Top-K Returns Three Versions of the Same Chunk

Простой метод поиска top-k работает неэффективно. Когда в корпусе текста одно и то же упоминается в нескольких местах, k ближайших фрагментов к запросу могут оказаться одним и тем же фрагментом, повторенным трижды с использованием разных слов.

Представьте 100-страничный отчет об изменении климата, в котором пять разделов посвящены парниковым газам; они изложены немного по-разному, но передают одну и ту же мысль. Кто-то задает вопрос: «Что вызывает изменение климата?», и индекс FAISS возвращает следующее:

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)

Это три фрагмента, которые по сути представляют собой один факт. Пользователь спрашивал о причинах во множественном числе, но получил одну и ту же причину снова и снова. Фрагменты, касающиеся вырубки лесов, промышленного земледелия и изменений в использовании земли, находились на 8-м, 12-м и 17-м местах, поэтому ни один из них не попал в контекст ИИ-модели. Ответ модели полностью соответствует её входным данным: связный абзац о парниковых газах без упоминания других причин.

Виновато не векторное поисковое обеспечение — оно выполнило свою задачу, вернув результаты с наибольшим сходством. Проблема в том, что понятие сходства не учитывает избыточность информации. Три фрагмента могут быть максимально близки к запросу и в то же время быть почти идентичными друг другу, и обычный алгоритм top-k вернёт все три, исключив при этом другие релевантные данные.

RAG для дартборда изменяет правило выбора. В то время как стандартный метод поиска запрашивает k фрагментов, наиболее близких к запросу, этот подход требует k фрагментов, каждый из которых является высокорелевантным и в то же время находится на значительном расстоянии друг от друга. В результате получаются релевантные части корпуса, а не несколько копий его лучшего варианта ответа.

Представьте себе дартборд

Рассматривайте запрос как центральную цель, а каждый фрагмент — как дротик, уже вонзившийся в доску. Чем ближе фрагмент к центру, тем выше его релевантность.

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

В обычном способе извлечения выбираются три самых близких дротика. Метод доски для дротиков выбирает три дротика, которые находятся близко друг к другу, но при этом расположены и на расстоянии.

Общая структура процесса

С архитектурной точки зрения это стандартный способ извлечения с дополнительным этапом более умного выбора. Хранилище векторов и эмбеддинги остаются прежними. Единственным новым элементом является этап жадного выбора, который определяет, какие из 15 кандидатов попадут в итоговый список из 5.

Шаг 1: загрузка большего количества данных, чем требуется

Алгоритм не может сделать выбор среди кандидатов, которых у него нет, поэтому ему нужен пул данных, больший, чем итоговое количество k. В качестве эталонной реализации используется коэффициент переотбора 3:

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

Таким образом, берутся 15 кандидатов, а не 5. FAISS возвращает их в порядке убывания косинусного сходства, при этом кандидат с номером 0 находится ближе всего к запросу, а кандидат с номером 14 — дальше всех. На этапе выбора затем определяется наилучшее подмножество из пяти элементов путем совместной оценки релевантности и разнообразия.

На следующем этапе требуются эмбеддинги каждого кандидата, а не только его текст, поэтому данные загружаются в виде полных объектов Document с привязанными к ним эмбеддингами.

Шаг 2: построение двух матриц расстояний

На основе эмбеддингов кандидатов вычисляются две матрицы:

# 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 содержит расстояние от запроса до каждого кандидата. Низкое значение означает высокую релевантность. Его форма — (1, N).
  • document_distances содержит парные расстояния между всеми парами кандидатов. Низкое значение означает, что эти кандидаты похожи друг на друга. Его форма — (N, N).

Релевантность определяется по первой матрице, а разнообразие — по второй.

Шаг 3: преобразование расстояний в лог-вероятности

Чистые расстояния плохо сочетаются между собой. Разница между 0,3 и 0,4 может казаться значимой, однако простое вычитание даёт 0,1, что недооценивает её. Поэтому алгоритм преобразует расстояния в лог-вероятности с использованием гауссовского ядра:

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

Это лог-график гауссовской плотности. В простых словах, небольшое расстояние соответствует высокой вероятности (близкость означает вероятную релевантность или сходство), большое расстояние — низкой вероятности, а sigma определяет насколько острая будет кривая. Небольшое значение sigma, по умолчанию 0.1, создаёт плотные пики, которые сильно реагируют на небольшие различия в расстояниях. Большее значение, например 0.5, сглаживает кривую, так что схожие расстояния рассматриваются как более одинаковые.

Работа в логарифмическом пространстве важна для числовой стабильности. При небольших расстояниях и небольшом значении sigma чистые вероятности сокращаются до крошечных чисел, которые превращаются в ноль из-за проблемы с плавающей точкой, тогда как логарифмические вероятности остаются в пределах определённых значений и сопоставимыми.

Шаг 4: цикл жадного выбора

Именно здесь реализуется логика работы с целью. Процедура выглядит следующим образом:

  1. Сначала берут наиболее релевантный элемент. Пока не требуется тест на разнообразие, поскольку ещё не выбрано ничего, с чем его можно было бы сравнить.
  2. Сохраняется значение max_distances, которое фиксирует для каждого кандидата максимальное расстояние в виде логарифма вероятности до любого выбранного документа. Чем больше это значение, тем больше кандидат отличается от текущего выбора.
  3. Для каждой оставшейся позиции оценивается каждый невыбранный кандидат путем суммирования показателя релевантности (степень близости к запросу, взятая из query_probs) и показателя разнообразия (расстояние от кандидата до ближайшего уже выбранного кандидата, взятое из max_distances). Результатом является выражение diversity_weight × diversity + relevance_weight × relevance.
  4. Суммарные оценки объединяются с помощью функции logsumexp, которая обеспечивает числовую стабильность.
  5. Выбирается кандидат с наивысшей суммарной оценкой, он добавляется, а значение max_distances обновляется.
  • Повторяйте процедуру до тех пор, пока не будет выбрано k кандидатов.
  • В коде:

    # 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)
    

    Основная идея заключается в суммарном балле. Каждый выбор оценивается по критериям «является ли это релевантным?» и «чем это отличается от того, что уже имеется?». Первый выбор максимизирует чистую релевантность, а каждый последующий выбор балансирует оба этих фактора.

    Как функционируют математические расчёты, в неформальной форме

    На каждом шаге цикл фактически задаётся вопрос: среди кандидатов, которые ещё не были выбраны, какой из них наилучшим образом улучшит как его счёт релевантности, так и охват соответствующей области?

    Представьте себе 2D-диаграмму разброса, где по оси X отображается релевантность, а по оси Y — расстояние от выбранного набора. Первый выбор — это точка, расположенная справа. У всех остальных кандидатов есть значение Y, соответствующее их расстоянию от этой первой точки.

    Для второго выбора необходимо что-то, находящееся далеко справа (релевантное), но в то же время высоко (в отличие от первого). Поскольку значения весов у обоих вариантов равны 1,0, баланс сбалансирован, поэтому цикл выберет фрагмент, который менее релевантен, но значительно отличается.

    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
    

    Метод plain top-k выбирает самые правые точки, независимо от того, насколько они сгруппированы. Метод Dartboard сначала берет самую правую точку, а затем целенаправленно расширяет поиск на другие релевантные области.

    Два веса

    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
    

    Оба параметра по умолчанию равны 1,0, что означает одинаковую степень акцента. Изменение их значений влияет на поведение алгоритма: значение диверситета увеличивается, если продолжают появляться дубликаты, а значение релевантности — если в результаты входят несвязанные с темой фрагменты. По умолчанию эти значения подходят для большинства корпусов.

    Пример работы с корпусом, богатым дубликатами

    В базовой реализации существует параметр duplicate_factor, который заставляет каждый фрагмент повторяться N раз; это удобный способ проверки качества метрики диверситета на искусственно насыщенном корпусе. Установите его на 5 и задайте вопрос «Что вызывает изменение климата?»

    Стандартный метод возврата топ-5 результатов дает следующее:

    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
    

    Пять результатов представляют собой буквально один блок, все они одинаково близки к запросу, поэтому большая языковая модель получает один факт, переформулированный пять раз.

    Рейтинг топ-5 от Dartboard показывает следующее:

    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
    

    Как только выбирается первый фрагмент, показатель разнообразия штрафует каждую его копию, поскольку у каждой из них разнообразие по отношению к выбранному фрагменту равно нулю. Поэтому цикл ищет уникальный контент в слотах с двумя по пять, и большая языковая модель получает пять разных фактов о причинах. Это оптимальный вариант. В реальных корпусах редко встречаются точные дубликаты, поэтому эффект менее выражен, хотя та же логика применяется и к почти идентичным фрагментам.

    Всё в примерно двадцати строках

    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
    

    Длина невелика. Сложность заключается в том, что означают математические формулы, а не в количестве строк. Как только правило становится ясным (выбрать наиболее релевантный фрагмент, затем отдать предпочтение кандидатам, которые одновременно релевантны и отличаются от уже выбранного), код становится легким для понимания.

    Параметры, которые стоит настроить

    Следует обратить внимание на два настройки.

    • oversampling определяет размер пула кандидатов. Значение по умолчанию 3 означает 15 кандидатов при окончательном выборе 5. Повышение этого значения до 5 или 10 предоставляет алгоритму больше возможностей, но замедляет вычисление парных расстояний. В корпусе с сильным дублированием использование 10× oversampling позволяет алгоритму выявлять более уникальные фрагменты на более поздних этапах ранжирования.
    • diversity_weight должен увеличиваться, если при оценке всё ещё обнаруживаются дубликаты, и снижаться, если результаты слишком сильно распыляются, причём выбираются фрагменты, не относящиеся к теме, лишь из-за того, что они «разные». Значение по умолчанию 1.0 считается сбалансированным, а значения 1.5 или 2.0 подходят для очень плотных корпусов.

    sigma более чувствителен к изменениям и редко требует корректировки. Сообщается, что значение по умолчанию 0.1 подходит для косинусных расстояний в эмбеддингах OpenAI. Если вы перейдёте на модель эмбеддингов с другими масштабами расстояний, возможно, потребуется его перенастроить, хотя большинство пользователей могут оставить его без изменений.

    Где это помогает, а где — нет

    Dartboard подходит для нескольких ситуаций.

    • Корпуса с почти одинаковыми данными. Множество источников, посвящённых одной теме, повторяющиеся документы с политикой, архивы новостей с перекрывающейся информацией, индексированные посты на форумах. Стандартный метод top-k здесь обычно терпит неудачу.
    • Важность охвата не меньше точности. Краткие обзоры исследований, подробные вопросы и ответы и сравнительный анализ, когда пользователь хочет получить всю информацию по теме, а не только один наиболее подходящий фрагмент.
    • Дополнительные вычислительные ресурсы. Дополнительные затраты связаны с матричными вычислениями над набором кандидатов, а не с дополнительными вызовами больших языковых моделей. Это практически не ощущается при k=5 и 15 кандидатах, но заметно при k=20 и более кандидатах.

    В таких случаях это помогает в меньшей степени.

    • Природно разнообразные корпуса данных. Если фрагменты уже имеют тематическую разницу благодаря семантическому разбиению или хорошо структурированным документам, метод top-k всё равно будет возвращать разнообразные фрагменты, а инструмент Dartboard лишь увеличивает нагрузку без изменения рейтинга.
    • Очень конкретные вопросы. На вопрос «Какая температура кипения ртути?» существует один правильный ответ. Логика обеспечения разнообразия может выбрать менее качественные фрагменты ради разнообразия, что негативно скажется на качестве ответа.
    • Очень маленькое значение k. При k равном 1 или 2 практически нет возможностей для диверсификации, поэтому обычный метод top-k подходит идеально.
    • Цепочки обработки, уже объединяющие методы слияния данных, переранжирования и использования окон. Каждый из этих методов решает связанные проблемы по-своему. Добавление инструмента Dartboard к уже сложной цепочке обработки редко помогает и увеличивает сложность.

    Основная идея

    Интересная особенность подхода Dartboard RAG заключается не в математических расчётах, а в понимании того, что сама по себе релевантность — неверная цель для метода извлечения данных top-k.

    Стандартный метод top-k определяет, какие k элементов являются наиболее релевантными по отдельности. Этот подход рассматривает каждый элемент изолированно и игнорирует то, что уже находится в наборе результатов; поэтому при наличии нескольких одинаково релевантных элементов они все включаются в результат, даже если они говорят об одном и том же.

    Лучшая цель заключается в том, чтобы определить, какие k элементов в совокупности наилучшим образом отвечают потребностям запроса. Это другой вид оптимизации, при котором оценка k-го элемента зависит от элементов с номерами от 1 до k-1, а не только от его собственной релевантности. Такие жадные методы, как Dartboard, представляют собой приемлемые приближения к этой оптимизации.

    Эта концепция находит применение и за пределами процесса извлечения данных:

    • Отбор обучающих данных. Техники активного обучения используют отбор с учётом разнообразия по той же причине.
    • Рекомендаторы. Предложение трех незначительно отличающихся вариантов одного продукта никому не помогает, поэтому разнообразие улучшает список.
    • Поиск по изображениям. Десять практически идентичных картинок мешают пользователю больше, чем десять разных релевантных изображений.
    • Извлекающее резюмирование. При выборе предложений необходимо учитывать разнообразие, а не только их значимость по отдельности.

    Общий урок заключается в том, что при выборе набора элементов следует оценивать сами наборы, а не отдельные элементы. Dartboard — это версия этого принципа в рамках технологии RAG, и как только вы его освоите, вы заметите ту же ловушку во многих других алгоритмах типа «определить ранг, затем взять топ-k».

    За это приходится платить: дополнительные вычисления и трудности с объяснением, почему один фрагмент лучше другого. Однако результатом является более полное покрытие информации, что часто приводит к лучшим ответам, особенно когда корпус данных не структурирован так, чтобы каждый отдельный факт занимал отдельный фрагмент.