RAG do tarczy do rzutu oszczepem: Co robić, gdy Top-K zwraca ten sam fragment trzy razy
Zwykłe wyszukiwanie top-k może zwracać fragmenty niemal identyczne. Dartboard RAG bierze pod uwagę zarówno istotność, jak i różnorodność; oto algorytm, kod oraz sytuacje, w których należy go używać.
Źródła: Dartboard RAG: When Top-K Returns Three Versions of the Same Chunk
Zwykłe wyszukiwanie top-k zawodzi w sposób niewidoczny. Gdy w korpusie tekstowym to samo stwierdzenie występuje w kilku miejscach, k najbliższych fragmentów odpowiadających zapytaniu może okazać się jeden fragment, w którym to samo stwierdzenie jest powtórzone trzy razy przy użyciu różnych słów.
Załóżmy raport o zmianach klimatycznych składający się z 100 stron, w którym pięć rozdziałów omawia gaz cieplarniany, przedstawionych nieco inaczej, ale podnoszących ten sam temat. Ktoś pyta: „Co powoduje zmiany klimatyczne?”, a indeks FAISS zwraca następujący wynik:
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)
To trzy fragmenty, które w praktyce stanowią jedną informację. Użytkownik zapytał o przyczyny w liczbie mnogiej, a otrzymał powtórzenie tej samej przyczyny. Fragmenty dotyczące wylesiania, rolnictwa przemysłowego i zmian w wykorzystaniu ziemi znajdowały się na pozycjach 8, 12 i 17, więc żaden z nich nie trafił do kontekstu modelu LLM. Odpowiedź modelu odzwierciedla jego wejście: płynny akapit o gazach cieplarnianych bez żadnej wzmianki o pozostałych przyczynach.
Poszukiwanie wektorowe nie jest winowajcą. Zrobiło to, co mu kazano, zwracając najbardziej podobne wyniki według kryterium top-k. Problem polega na tym, że kryterium to nie uwzględnia redundancji. Trzy fragmenty mogą być maksymalnie bliskie zapytaniu, a jednocześnie stanowić niemal identyczne kopie siebie; zwykłe kryterium top-k zwróci wszystkie trzy, pomijając przy tym inne, istotne informacje.
Dartboard RAG zmienia zasadę selekcji. Podczas gdy standardowe metody wyszukiwania szukają k fragmentów najbliższych zapytaniu, ten podejście wymaga k fragmentów, które są każdy wysoce istotne i jednocześnie znajdują się daleko od siebie. W ten sposób uzyskuje się informacje obejmujące istotne części korpusu, a nie kilka kopii jego najlepszego wyniku.
Wyobraź sobie tarczę do rzucania oszczepami
Traktuj zapytanie jako cel, a każdy fragment jako oszczep już wbity w tarczę. Im bliżej środka, tym bardziej istotny jest dany fragment.
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
Zwykłe pobieranie obejmuje trzy najbliższe strzały. Metoda tarczy do strzał zbiera trzy strzały, które są blisko siebie, ale jednocześnie rozrzucone.
Całkowity proces
Pod względem architektury jest to standardowe pobieranie z dodanym inteligentnym wybieraniem na górze. Magazyn wektorów i embeddingi pozostają dokładnie takie same. Jedyną nową elementem jest krok chciwego wyboru, który decyduje, które z 15 kandydatów trafi do końcowych 5.
Krok 1: pobrać więcej, niż jest potrzeba
Algorytm nie może wybrać spośród kandydatów, których nie ma, dlatego potrzebuje zbiórki większej niż końcowa liczba k. Implementacja referencyjna mnoży liczbę kandydatów przez współczynnik oversamplingu równy 3:
fetch_k = self.k * self.oversampling # default: 5 × 3 = 15
candidates = vector_store.search(query_embedding, k=fetch_k)
Zatem wybierasz 15 kandydatów, a nie 5. FAISS zwraca je uszeregowane według podobieństwa kosinowego, co umieszcza kandydata nr 0 najbliżej zapytania, a kandydata nr 14 najdalej. Następnie w kroku selekcji wybierany jest najlepszy zbiór pięciu kandydatów poprzez połączone ocenianie ich istotności i różnorodności.
Następny krok wymaga embeddingów każdego kandydata, a nie tylko jego tekstu, dlatego zbiór ten jest pobierany jako pełne obiekty Document z dołączonymi embeddingami.
Krok 2: utworzenie dwóch macierzy odległości
Na podstawie embeddingów kandydatów oblicza się dwie macierze:
# 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_distancesprzechowuje odległość od zapytania do każdego kandydata. Niska wartość oznacza wysoką relewanckość. Jego kształt to (1, N).document_distancesprzechowuje odległości pomiędzy każdą parą kandydatów. Niska wartość oznacza, że ci kandydaci są do siebie podobni. Jego kształt to (N, N).
Relewanckość pochodzi z pierwszej macierzy, a różnorodność z drugiej.
Krok 3: przekształcenie odległości w log-prawdopodobieństwa
Surowe odległości nie łączą się dobrze. Różnica między 0,3 a 0,4 może wydawać się znacząca, jednak zwykłe odejmowanie daje 0,1 i umniejsza to znaczenie. Dlatego algorytm mapuje odległości na log-prawdopodobieństwa przy użyciu jądra gaussowskiego:
def lognorm(dist, sigma):
return -np.log(sigma) - 0.5 * np.log(2 * np.pi) - dist**2 / (2 * sigma**2)
To jest wykres gęstości gaussowskiej. Mówiąc w prostych słowach, mała odległość oznacza wysoką prawdopodobieństwo (bliskość sugeruje, że dane są prawdopodobnie istotne lub podobne), duża odległość natomiast oznacza niskie prawdopodobieństwo, a sigma określa ostrość krzywej. Małe wartości sigma, z domyślną wartością 0,1, tworzą wąskie szczyty, które silnie reagują na małe różnice w odległościach. Większe wartości sigma, takie jak 0,5, spłaszczają krzywą, dzięki czemu podobne odległości są traktowane w podobny sposób.
Praca w przestrzeni logarytmicznej ma znaczenie dla stabilności numerycznej. Przy małych odległościach i małej wartości sigma surowe prawdopodobieństwa zmniejszają się do bardzo małych liczb, które mogą spaść poniżej zera, natomiast prawdopodobieństwa w przestrzeni logarytmicznej pozostają ograniczone i porównywalne.
Krok 4: pętla chciwa
Tutaj właśnie znajduje się logika działania tego mechanizmu. Procedura wygląda następująco:
- Najpierw wybierz najbardziej istotny fragment. Na razie nie ma potrzeby przeprowadzania testu różnorodności, ponieważ jeszcze nic nie zostało wybrane do porównania.
- Zachowaj wartość
max_distances, która rejestruje dla każdego kandydata największą odległość opartą na logarytmicznej prawdopodobieństwie od dowolnego wybranego dokumentu. Im większa wartość, tym bardziej kandydat różni się od obecnej selekcji. - Dla każdej pozostałej pozycji ocenij każdego niewybranego kandydata, łącząc wskaźnik istotności (jak bliski jest zapytaniu, na podstawie
query_probs) z wskaźnikiem różnorodności (jak daleki jest od najbliższego już wybranego kandydata, na podstawiemax_distances). Wynik todiversity_weight × diversity + relevance_weight × relevance. - Zsumuj połączone oceny za pomocą funkcji logsumexp, która zapewnia stabilność numeryczną.
- Wybierz kandydata o najwyższej łącznej ocenie, dodaj go i zaktualizuj wartość
max_distances.
W kodzie:
# 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)
Główną ideą jest łączna ocena. Każdy wybór rozważa pytanie „czy to jest istotne?” w zestawieniu z pytaniem „czy to różni się od tego, co już mamy?”. Pierwszy wybór maksymalizuje czystą istotność, a każdy kolejny wybór równoważy oba te czynniki.
Co informacyjnie robi matematyka
Na każdym kroku pętla faktycznie pyta: spośród kandydatów, których jeszcze nie wybrano, który z nich najlepiej poprawi zarówno jego ocenę istotności, jak i zakres obszaru o znaczeniu?
Załóżmy 2D wykres rozproszeń, gdzie osią x jest istotność, a osią y odległość od wybranego zbioru. Pierwszy wybór to punkt najdalszy w prawo. Wszystkie pozostałe kandydaty mają wartość y odpowiadającą ich odległości od tego pierwszego wyboru.
Dla drugiego wyboru chcesz coś znajdującego się daleko po prawej stronie (istotnego) i jednocześnie wysoko (w odróżnieniu od pierwszego). Ponieważ oba wagi wynoszą 1,0, równowaga jest zachowana, więc pętla wybierze fragment nieco mniej istotny, ale znacznie bardziej odmienny.
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
Metoda top-k bierze najbardziej prawe punkty, bez względu na to, jak bardzo są ze sobą skupione. Metoda tarczy strzeleckiej najpierw bierze najbardziej prawy punkt, a następnie celowo rozszerza się na inne istotne obszary.
Dwa wagi
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
Oba ustawienia domyślne wynoszą 1,0, co oznacza równy nacisk. Ich zmiana wpływa na zachowanie: zwiększ różnorodność, jeśli nadal pojawiają się duplikaty, oraz zwiększ istotność, jeśli pojawiają się fragmenty niepowiązane z tematem. Domyślne ustawienia nadają się dla większości korpusów.
Przykład z korpusem bogatym w duplikaty
Wdrożenie referencyjne posiada parametr duplicate_factor, który powtarza każdy fragment N razy – to przydatny sposób na sprawdzenie różnorodności w syntetycznie gęstym korpusie. Ustaw go na 5 i zadaj pytanie „Co powoduje zmiany klimatyczne?”
Standardowe wydobywanie 5 najlepszych wyników daje następujący rezultat:
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
Pięć wyników stanowi dosłownie jedną całość, wszystkie są równie bliskie zapytaniu, więc model językowy otrzymuje jeden fakt przedstawiony pięć razy.
Dartboard top-5 pokazuje to w następujący sposób:
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
Gdy zostanie wybrany pierwszy fragment, wskaźnik różnorodności kara każdą jego kopię, ponieważ każda z nich ma zerową różnorodność w stosunku do wybranej. Dlatego pętla szuka unikalnych treści w pozycjach od drugiej do piątej, a model językowy widzi pięć różnych faktów na temat przyczyn. To najlepszy scenariusz. Rzeczywiste zbiory danych rzadko zawierają dokładne duplikaty, więc efekt jest łagodniejszy, chociaż ta sama zasada obowiązuje w przypadku niemal identycznych treści.
Całość w około dwudziestu liniach
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
Długość jest umiarkowana. Trudność polega na tym, co oznaczają obliczenia matematyczne, a nie na liczbie wierszy. Gdy zasada stanie się jasna (wybrać najbardziej istotny fragment, a następnie preferować te kandydaty, które są zarówno istotne, jak i różne od tych już wybranych), kod jest łatwy do zrozumienia.
Parametry wartych dostosowania
Dwie ustawienia wymagają uwagi.
oversamplingokreśla rozmiar zbioru kandydatów. Domyślna wartość 3 oznacza 15 kandydatów przy końcowych 5. Podniesienie tej wartości do 5 lub 10 daje algorytmowi więcej materiału do przetworzenia, ale spowalnia obliczanie odległości pomiędzy elementami. W korpusie silnie powtarzalnym 10× oversampling pozwala narzędziu dotrzeć do bardziej odległych w rankingu fragmentów, które są rzeczywiście unikalne.diversity_weightpowinien wzrastać, jeśli w ocenach nadal pojawiają się duplikaty, oraz spadać, jeśli wyniki są zbyt rozproszone, a wybrane fragmenty poza tematem są takie jedynie dlatego, że są „inne”. Domyślna wartość 1,0 zapewnia równowagę, a wartości 1,5 lub 2,0 są odpowiednie dla bardzo gęstych korpusów.
sigma jest bardziej delikatny i rzadko wymaga zmiany. Podobno domyślna wartość 0,1 sprawdza się przy odległościach kosinowych w embeddingach OpenAI. Jeśli przejdziesz na model embeddingów z innymi skalami odległości, możesz potrzebować jego ponownej kalibracji, chociaż większość osób może go pozostawić bez zmian.
Gdzie to pomaga, a gdzie nie
Dartboard nadaje się do kilku sytuacji.
- Korpora z niemal identycznymi treściami. Wiele źródeł dotyczących jednego tematu, powtarzalne dokumenty polityczne, archiwa wiadomości z nakładającymi się materiałami, skatalogowane posty na forach. Standardowe podejście typu top-k często zawodzi w takich przypadkach.
- Rozmiar zakresu informacji jest równie ważny jak precyzja. Streszczenia badań, szczegółowe pytania i odpowiedzi oraz analizy porównawcze – w takich sytuacjach użytkownik chce mieć dostęp do całego zakresu istotnych informacji, a nie tylko jednego najlepszego fragmentu.
- Dodatkowe zasoby obliczeniowe. Dodatkowy koszt wynika z operacji matematycznych na zbiorze kandydatów, a nie z dodatkowych wywołań modeli językowych. Jest to niewyczuwalne przy k=5 i 15 kandydatach, natomiast widoczne przy k=20 i 60 lub więcej kandydatach.
W tych przypadkach jest to mniej pomocne.
- Naturalnie zróżnicowane korpusy. Jeśli fragmenty są już tematycznie odrębne, dzięki semantycznemu dzieleniu na fragmenty lub dobrze zstrukturyzowanym dokumentom, metoda top-k i tak zwraca różnorodne fragmenty, a narzędzie dartboard dodaje tylko obciążenie bez zmiany rankingu.
- Bardzo konkretne pytania. „Jaka jest temperatura wrzenia rtęci?” ma jedną dobrą odpowiedź. Logika zróżnicowania może wybrać słabsze fragmenty dla zachowania różnorodności, co pogorszy jakość odpowiedzi.
- Niewielkie wartości k. Przy k równym 1 lub 2 niewiele jest możliwości na zróżnicowanie, więc zwykła metoda top-k wystarcza.
- Pipeline’y, które już łączą fuzję, ponowny ranking i mechanizmy okienkowe. Każdy z tych elementów radzi sobie z powiązanymi problemami na swój sposób. Dodawanie narzędzia dartboard do już złożonego pipeline’a rzadko pomaga i tylko zwiększa złożoność.
Idea do zapamiętania
Ciekawym elementem w Dartboard RAG nie są obliczenia matematyczne. Chodzi o zrozumienie, że sama istotność nie jest właściwym celem przy metodzie wybierania top-k.
Standardowa metoda top-k pyta, które k elementów jest indywidualnie najbardziej istotnych. Traktuje to każdy element oddzielnie i ignoruje to, co już znajduje się w zestawieniu wyników, więc gdy istnieje kilka równie istotnych elementów, otrzymuje się je wszystkie, nawet jeśli mówią to samo.
Lepszy cel zakłada analizę całego zbioru: które k elementów razem najlepiej odpowiadają potrzebom zapytania? To inna forma optymalizacji, w której ocena elementu k zależy od elementów od 1 do k-1, a nie tylko od jego własnej istotności. Metody takie jak Dartboard stanowią praktyczne aproksymacje tej metody.
Ten wzorzec występuje również poza dziedziną wydobywania informacji:
- Próbkowanie danych treningowych. Techniki uczenia aktywnego wykorzystują próbkowanie uwzględniające różnorodność z tego samego powodu.
- Zalecane produkty. Proponowanie trzech niewielkich wariantów jednego produktu nikomu nie pomaga, więc różnorodność poprawia listę.
- Szukanie za pomocą obrazów. Takie niemal identyczne zdjęcia są gorsze dla użytkownika niż dziesięć różnych, relevantnych obrazów.
- Streszczanie wydobywcze. Wybieranie zdań wymaga różnorodności, a nie tylko oceny ich ważności.
Ogólna lekcja brzmi tak, że przy wyborze zestawu elementów należy oceniać same zestawy, a nie poszczególne pozycje. Dartboard to wersja tego zasady w ramach technologii RAG, a po jego zrozumieniu dostrzeżesz tę samą pułapkę w wielu innych procesach typu „uporządkuj, a następnie weź najlepsze k elementów”.
Istnieje pewna cena: dodatkowe obliczenia oraz trudność w wyjaśnieniu, dlaczego jeden fragment radzi sobie lepiej niż inny. Nagrodą jest lepsze objęcie tematu, co często przekłada się bezpośrednio na lepsze odpowiedzi, szczególnie gdy korpus nie jest uporządkowany tak, by każdy fakt stanowił oddzielny fragment.