飞镖靶 RAG:当 Top-K 三次返回相同片段时该怎么做
普通的top-k检索可能会返回近似重复的片段。Dartboard RAG则同时考虑相关性与多样性;以下是该算法、代码以及适用场景。
来源: 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位,因此都没有进入大语言模型的上下文范围。模型的回答与输入内容如出一辙:只是一段关于温室气体的流畅叙述,完全没有提及其他成因。
向量搜索并非问题所在。它只是按照指令,根据相似度返回排名靠前的内容。问题在于相似度算法并不具备识别冗余信息的能力。三个片段可能都与查询内容极为相似,同时又几乎是彼此的复制品,而简单的排名机制会把这三个片段全部呈现出来,从而排挤掉其他相关但不同的内容。
Dartboard 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个候选项最远。随后在筛选阶段会综合考虑相关性与多样性,选出最佳的5个候选项子集。
下一步需要每个候选项的嵌入向量,而不仅仅是其文本,因此会获取带有嵌入向量的完整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则决定了曲线的陡峭程度。默认值为0.1的较小sigma值会生成较为尖锐的峰值,对微小的距离差异反应强烈;而如0.5这样较大的sigma值则会使曲线变平,从而使距离相近的点被更视为相同。
在対数空间中进行计算有助于保持数值稳定性。当距离较近且sigma值较小时,原始概率会缩小为极小的浮点数,进而下溢为零,而对数概率则仍保持在一定范围内且可相互比较。
第4步:贪心循环
这就是“飞镖靶”逻辑实际运用的地方。具体流程如下:
- 首先选取最相关的候选项。由于尚未选择任何用于比较的项,因此无需进行多样性测试。
- 保持
max_distances的值,该参数记录每个候选项与已选文档之间的最大对数概率距离。数值越大,表示该候选项与当前选择项的差异越大。 - 对于每个剩余的位置,通过将相关性指标(来自
query_probs,表示其与查询的接近程度)与多样性指标(来自max_distances,表示其与最近已选候选项的距离)相结合来为每个未选候选项打分。综合得分公式为diversity_weight × diversity + relevance_weight × relevance。 - 使用数值稳定的logsumexp函数对所有综合得分进行汇总。
- 选取综合得分最高的候选项,将其加入结果集,并更新
max_distances的值。
代码实现:
# 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)
核心理念是综合得分。每次选择都会权衡“这与当前需求相关吗?”与“它与已有的内容有何不同?”这两个因素。第一次选择会最大化相关性,而后续的选择则需要在两者之间取得平衡。
用通俗的方式解释数学原理
在每一步中,算法实际上都在询问:在尚未被选中的候选项中,哪一个最能同时提升其相关性得分以及相关领域的覆盖范围?
可以想象一个二维散点图,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
普通的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参数,用于指定每个片段重复的次数,这是对高度重复的语料库进行多样性压力测试的有效方法。将其设置为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
这五个结果实际上是一个整体,它们与查询内容的距离都相等,因此大语言模型接收到的只是同一事实的五次重复表述。
Dartboard的前5名结果如下:
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倍的过采样比例有助于让dartboard在排序中更深入地挖掘真正独特的片段。
diversity_weight的值:如果评估结果显示仍有重复内容漏过,该值应上调;若结果分布过于分散,且某些与主题无关的内容仅因“不同”就被选中,则该值应下调。默认值为1.0,属于平衡状态;对于内容极为密集的语料库,1.5或2.0也是合理的数值。sigma的设置较为微妙,很少需要调整。默认值0.1被证明适用于OpenAI嵌入模型上的余弦距离计算。如果更换为具有不同距离尺度的嵌入模型,可能需要重新校准该值,不过大多数人可以直接保持默认设置。
其适用场景与局限性
Dartboard适用于多种情况。
- 存在近似重复内容的语料库。许多涉及同一主题的资料来源、重复的政策文件、报道内容重叠的新闻档案以及被索引的论坛帖子。标准的top-k算法在此类情况下往往表现不佳。
- 覆盖范围与精确度同样重要。在研究总结、详尽的问答及对比分析中,用户需要的是所有相关的信息,而不仅仅是最优的那部分内容。
- 额外的计算资源需求。额外成本源于对候选项进行矩阵运算,而非更多的大语言模型调用。当候选项为15个且k=5时几乎感觉不到差异,但当候选项达到60个及以上且k=20时则会有明显影响。
在这些情况下它的作用较为有限。
- 天然多样化的语料库。如果通过语义分块或结构良好的文档,各片段本身在主题上就已经存在差异,那么top-k方法无论如何都会返回多样化的片段,而Dartboard机制只会增加开销而不会改变排序结果。
- 非常具体的问题。“水的沸点是多少?”这类问题只有一个标准答案。为了追求多样性,多样性算法可能会选择质量较差的片段,从而影响答案的准确性。
- 极小的k值。当k为1或2时,几乎无法实现多样性,因此使用普通的top-k方法即可。
- 已整合融合、重排序和窗口机制的流程。这些方法各自以不同的方式解决相关问题,在复杂的流程之上再添加Dartboard机制往往毫无帮助,反而会增加系统复杂性。
需要记住的核心思想
Dartboard RAG的巧妙之处不在于数学算法,而在于它意识到仅追求相关性并不足以作为top-k检索的正确目标。
传统的top-k方法会分别判断哪k个项目最相关,这种方式将每个项目视为独立个体,忽略结果集中已有的内容;因此当存在多个相关性相同的项时,即使它们表达的内容完全一致,也会全部被纳入结果。
更优的目标是考虑整个集合:哪k个项目加在一起最能满足查询需求?这是一种不同的优化方式,其中第k个项目的得分取决于前k-1个项目的表现,而不仅仅是其自身的相关性。像Dartboard这样的贪婪算法就是对此类问题的可行近似解。
这种思路在检索领域之外也有广泛应用:
- 训练数据采样。主动学习技术也出于相同原因采用注重多样性的采样方法。
核心原则是:在挑选一组事物时,应评估整个组而非单个项目。“飞镖板”模型正是该原则在RAG技术中的应用,一旦理解了这一原理,就会发现许多其他“先排序再取前k项”的流程也存在同样的问题。
这也有其代价:需要额外的计算资源,而且更难以解释为何某个处理方式会比另一种更好。但其优势在于能实现更全面的覆盖,这通常会直接带来更好的答案,尤其是在语料库并非按照每个独立事实一个块来整齐排列时。
相关阅读
- Agent在生产环境中的工具使用:来源追溯、重试机制与可观测性 —— 某银行开发的Agent错误调用了付费API并编造了电话号码。本文介绍了最基本的工具集、参数来源追溯、三级重试机制以及针对不同工具的指标体系。