登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  科技周边 >  人工智能

RAG 混合检索结果重复时怎么做去重和排序

来源:17golang原创

时间:2026-09-08 01:35:16 244浏览 收藏

RAG 的关键词检索和向量检索各自返回一份候选时,重复结果并不代表召回失败,真正的问题是“重复项按什么身份合并、合并后怎么排”。实用做法是先用稳定的文档 ID 归并两路候选,再保留最有价值的切片;排序上优先使用 RRF(Reciprocal Rank Fusion),因为 BM25 分数和向量相似度通常不在同一尺度上,不能直接相加。

一条可落地的规则是:切片层保留证据,文档层负责去重,排名层使用 RRF,最后再按业务需要做一次轻量重排。对于编号、型号这类精确词,提高关键词通道的候选覆盖比盲目调整向量阈值更有效。
要点速览
  • 先确定去重粒度:同文档不同切片不是同一条结果。
  • 两路结果先带上来源排名,再融合,不要直接比较原始分数。
  • RRF 负责稳定合并,Top-K 截断前保留可解释的 doc_id、chunk_id 和来源。

为什么混合检索会出现重复结果

关键词检索擅长命中“ORD-2026-0187”“ZX-450”这样的原词,向量检索擅长理解“订单被拒绝的原因”和“采购单审核失败”之间的语义关系。两路都找到同一份文档时,如果结果对象使用的是 chunk_id,看起来可能是多条;如果它们属于同一个 doc_id,对用户来说通常只是同一份文档的重复展示。

因此要先定义身份层级。推荐保留 doc_id 作为展示去重键,chunk_id 作为证据定位键。一个文档命中多个切片时,保留融合分数最高的切片;若回答确实需要上下文,再保留一条相邻且信息互补的切片。不要在召回刚返回时按文本完全相等去重,因为相邻切片文字不同,却可能来自同一篇手册。

RAG 混合检索中关键词 BM25 与向量 Embedding 共同产生候选文档的静态关系图
图1:关键词通道负责精确词,向量通道负责语义描述,二者在候选文档层汇合。

两路候选怎样保留有用信息

每条候选至少保存 doc_idchunk_idrankscoresourcesource 可以是 sparsedense,不要在融合前丢掉它,否则出现重复或排序异常时很难判断是哪一路贡献了结果。

两路通常各取比最终 Top-K 更大的候选池,例如最终展示 5 条时先各取 20 条。这个数字不是固定答案,关键是给融合和去重留出空间。编号查询可以让关键词池更充分;自然语言查询则要避免向量池过窄。若过滤条件(租户、权限、文档状态)会影响可见范围,应在两路检索中使用一致的过滤规则。

def merge_candidates(sparse_hits, dense_hits, limit=5):
    # 用文档 ID 做展示层去重,用切片 ID 保留证据位置
    grouped = {}
    for hit in sparse_hits + dense_hits:
        doc_id = hit["doc_id"]
        old = grouped.get(doc_id)
        # 同一文档只留下融合前更有价值的切片,来源和排名仍然保留
        if old is None or hit["score"] > old["score"]:
            grouped[doc_id] = hit
    # 这里先按候选分数排序,正式系统可在此处接入 RRF 分数
    return sorted(grouped.values(), key=lambda item: item["score"], reverse=True)[:limit]

上面的示例只说明“按文档归并”的数据边界,不建议把两路原始分数直接放进同一个排序器。BM25 和向量相似度的数值范围、分布和含义不同,直接相加会让某一路因为量纲更大而长期占优。

RRF 和加权融合该怎么选

RRF 不比较原始分数,只看候选在各自列表里的名次。一个文档在关键词列表排名第 2、在向量列表排名第 5,就把两次排名贡献相加:

def rrf_score(ranks, rank_constant=60):
    # ranks 是同一文档在各召回列表中的名次,名次从 1 开始
    return sum(1.0 / (rank_constant + rank) for rank in ranks)

def fuse_by_rank(sparse_hits, dense_hits, limit=5):
    # 先按 doc_id 建立两路排名,重复文档会自然合并
    ranks = {}
    for source_hits in (sparse_hits, dense_hits):
        for rank, hit in enumerate(source_hits, start=1):
            ranks.setdefault(hit["doc_id"], []).append(rank)
    scored = [
        {"doc_id": doc_id, "score": rrf_score(doc_ranks)}
        for doc_id, doc_ranks in ranks.items()
    ]
    # 最后再截断,避免某一路先截断导致另一条证据失去机会
    return sorted(scored, key=lambda item: item["score"], reverse=True)[:limit]

Elastic 的官方文档把 RRF 定义为合并多个结果集的排名方法,并说明 rank_constant 越大,低排名结果的影响越明显;NVIDIA RAG Blueprint 也把 hybrid 检索和 reranker 作为可配置的检索链路。工程上可以先用 RRF 获得稳定基线,再根据评测数据调整候选池大小和重排策略。

只有当业务明确要求“精确编号必须优先”时,才考虑加权融合。此时先把两路分数分别归一化,再使用例如 0.65 * sparse + 0.35 * dense 的可解释权重;这个比例只是起点,不能当成通用最优值。没有标注查询集时,RRF 通常比凭感觉调权重更容易维护。

RAG 混合检索按文档 ID 去重并通过 RRF 排名输出最终 Top-K 的静态关系图
图2:融合层把关键词候选和向量候选按文档 ID 合并,再输出稳定的最终 Top-K。

去重和排序完成后检查什么

不要只看“最终列表没有重复”就结束。至少准备三类小样本:包含订单号或产品型号的精确查询、完全改写的自然语言查询、同时包含编号和描述的混合查询。分别记录首个正确文档的位置、最终 Top-K 的文档重复率、两路各自覆盖了多少正确文档,以及同一文档保留的是哪个切片。

现象优先检查处理方向
列表有多条同文档结果doc_id 与 chunk_id 是否混用文档层去重,切片层留证据
编号命中靠后关键词候选池和过滤条件扩大 sparse 候选,再观察 RRF
语义结果被精确词挤掉融合前是否过早截断两路先取更大池,最后统一 Top-K

还要把权限过滤、文档状态和索引更新时间纳入回归样本。若同一文档的旧切片与新切片同时出现,应先在索引侧处理版本状态,而不是靠展示层随机删除一条。对 RRF 的 rank_window_size 也要保持稳定,否则分页时可能出现顺序漂移。

RAG 混合检索常见问题

应该按文本内容去重吗?

不建议。文本相同只能说明内容相似,不能可靠识别文档身份。优先使用稳定的 doc_id,再用 chunk_id 定位证据。

RRF 是否还需要向量分数?

RRF 排序本身只使用各列表排名,但可以继续保存原始向量分数用于解释、调试或后续重排,不要把它和 RRF 分数直接混加。

什么时候应该调大候选池?

当正确文档只出现在某一路的较后位置,或去重后剩余文档不足 Top-K 时,先调大该路召回数量,再观察延迟和覆盖率。

同一文档要保留几个切片?

默认保留一个最高贡献切片;回答需要跨段上下文时再增加一个相邻且互补的切片,并设置每个文档的上限,避免上下文被单一文档占满。

混合检索的核心不是把两份列表简单拼接,而是把“身份、排名、证据”三个层次分开:用文档 ID 消除展示重复,用 RRF 处理不同分数尺度,用切片 ID 保留可追溯证据。这样再接入业务权重或 reranker,调参也会有清晰的落点。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>