RAG 的 Top-K 为什么都是同一篇文档?Python 候选去重与证据覆盖的 10 项实验

RAG 返回了十段文本,却几乎都来自同一篇文档时,先查看候选列表:重叠切块、重复入库、同一文档的相邻段落,都可能占用多个位置。只增大最终 Top-K 不一定能补上适用条件、例外或其他来源。

本文用一个纯 Python 候选选择器展示问题。2026-09-15 在 Python 3.12.14 下运行 10 项检查,全部通过。分数与文档均为人工构造,没有调用 embedding、向量数据库、重排模型或生成模型,也没有测量回答准确率。

先看候选里装了什么

构造的顺序是:A 文档的旧版本、当前用户不可见的文档、A 当前版本的正文、同一正文的重复块、A 的另一段说明、B 的适用条件、C 的例外条件。高分不代表可使用,也不代表覆盖了不同问题。

把前四条先截断,再做权限、版本和重复过滤,最终只剩 A 的一条。允许从完整样本选择时,在每篇最多一段的演示规则下能留下 A、B、C。这里的“三个来源”只是可验证的选择结果,不能直接解释成答案更正确。

明确选择规则

第一步检查应用提供的访问权限、当前版本、非空正文与有限数值分数。真实系统应尽早在检索层应用权限过滤,避免未授权正文进入后续处理;这个离线函数只是末端检查的教学样例,不是一套完整授权系统。

第二步按分数降序、文档 ID 和块 ID 排序。同分时加入明确顺序,让输入次序变化的样本仍能复现。这里假设同一文档版本的块 ID 唯一;重复标识应在入库时检查。

第三步去掉同一文档版本内、strip 后完全相同的正文,再执行每文档上限和总字符预算。跨文档同文句子保留各自出处;本文没有实现近似或语义去重,也不会把所有空白差异都归一化。

最后持续扫描后续候选,最多选 k 条。遇到过大的块就跳过并继续寻找能放下的候选,不直接停止。这是一个确定性的贪心示例,并不保证预算下总相关性最优。

完整代码

保存为 retrieval_demo.py,运行 python3 retrieval_demo.py,不需要安装第三方库。候选、权限与版本元数据由测试构造,不能在真实产品中让模型自行提供权限结论。

retrieval_demo.py

"""Deterministic candidate selection. No embedding model or vector DB calls."""
from collections import Counter
from dataclasses import dataclass
from math import isfinite


@dataclass(frozen=True)
class Hit:
    doc: str
    version: int
    chunk: str
    text: str
    score: float


def select_evidence(hits, allowed, current, *, k=3, per_doc=1, char_budget=500):
    if any(type(v) is not int or v <= 0 for v in (k, per_doc, char_budget)):
        raise ValueError("positive integer limits required")
    # Authoritative permission/version metadata must come from the application.
    eligible = [h for h in hits if h.doc in allowed
                and current.get(h.doc) == h.version
                and h.text.strip() and isfinite(h.score)]
    ordered = sorted(eligible, key=lambda h: (-h.score, h.doc, h.chunk))
    selected, seen, counts = [], set(), Counter()
    used = 0
    for h in ordered:
        # Only identical text within the same document version is deduplicated.
        key = (h.doc, h.version, h.text.strip())
        if key in seen or counts[h.doc] >= per_doc:
            continue
        cost = len(h.text)  # Demo uses characters, NOT model tokens.
        if used + cost > char_budget:
            continue
        selected.append(h)
        seen.add(key)
        counts[h.doc] += 1
        used += cost
        if len(selected) == k:
            break
    return selected


if __name__ == "__main__":
    hits = [
        Hit("A", 1, "old", "旧版本:需要离线窗口。", .999),
        Hit("PRIVATE", 1, "secret", "不可对当前用户返回的文本。", .998),
        Hit("A", 2, "a1", "当前操作说明:先检查服务状态。", .99),
        Hit("A", 2, "a2", "当前操作说明:先检查服务状态。", .98),
        Hit("A", 2, "a3", "当前操作说明:再查看配置来源。", .97),
        Hit("B", 1, "b1", "适用条件:只支持测试环境。", .90),
        Hit("C", 1, "c1", "例外条件:只读账号不能执行写入。", .85),
    ]
    allowed, current = {"A", "B", "C"}, {"A": 2, "B": 1, "C": 1}
    done = []

    def check(name, condition):
        assert condition, name
        done.append(name)
        print("PASS", name)

    result = select_evidence(hits, allowed, current)
    check("filter_permissions", all(h.doc != "PRIVATE" for h in result))
    check("filter_old_versions", all(h.version == current[h.doc] for h in result))
    check("per_document_limit", [h.doc for h in result] == ["A", "B", "C"])
    exact = select_evidence(hits, allowed, current, k=10, per_doc=10)
    check("exact_duplicate_removed", [h.chunk for h in exact] == ["a1", "a3", "b1", "c1"])
    truncated = select_evidence(hits[:4], allowed, current)
    check("early_topk_loses_candidates", len(truncated) == 1 and len(result) == 3)
    tight = select_evidence(hits, allowed, current, char_budget=35)
    check("character_budget", sum(len(h.text) for h in tight) <= 35 and len(tight) == 2)
    ties = [Hit("B", 1, "b", "乙", .5), Hit("A", 2, "a", "甲", .5)]
    check("stable_tie_order", select_evidence(ties, allowed, current) == select_evidence(ties[::-1], allowed, current))
    dirty = [Hit("A", 2, "empty", " ", 1), Hit("A", 2, "nan", "甲", float("nan"))]
    check("empty_and_nan_skipped", select_evidence(dirty, allowed, current) == [])
    cross_doc = [Hit("A", 2, "a", "共同提示", .9), Hit("B", 1, "b", "共同提示", .8)]
    check("cross_document_provenance_kept", len(select_evidence(cross_doc, allowed, current)) == 2)
    oversized = [Hit("A", 2, "a", "大" * 100, .99), Hit("B", 1, "b", "短证据", .8)]
    check("skip_oversized_keep_later", [h.doc for h in select_evidence(oversized, allowed, current, char_budget=10)] == ["B"])
    print("selected:", [(h.doc, h.version, h.chunk) for h in result])
    print("early_top4_then_filter:", [(h.doc, h.chunk) for h in truncated])
    print("TOTAL:", len(done))

本次输出

retrieval_output.txt

PASS filter_permissions
PASS filter_old_versions
PASS per_document_limit
PASS exact_duplicate_removed
PASS early_topk_loses_candidates
PASS character_budget
PASS stable_tie_order
PASS empty_and_nan_skipped
PASS cross_document_provenance_kept
PASS skip_oversized_keep_later
selected: [('A', 2, 'a1'), ('B', 1, 'b1'), ('C', 1, 'c1')]
early_top4_then_filter: [('A', 'a1')]
TOTAL: 10

十项分别检查权限过滤、旧版本过滤、每文档上限、精确重复、提前截断丢候选、字符预算、同分顺序、空值与 NaN、跨文档出处,以及跳过超长候选后继续选择。

哪些参数不能照抄

per_doc=1 只是放大“同源挤占”的演示。如果问题要完整解释 A 文档里的多步流程,强行只取一段会丢掉后续步骤。可以按问题类型设置上限,或检索小块后补充其所属章节;最终仍要检查补充上下文的权限、版本和预算。

char_budget 用 len(text) 计算 Python 字符数量,不是模型 token 数。标题、来源标记、提示词、消息结构与预留输出都没算进去。生产接入需要对实际序列化后的完整输入,使用目标模型对应的计数方式检查预算,不能把本文的 500 理解成任何模型的上下文上限。

检索候选数和最终证据数应分开配置。只有四条候选,后处理不能凭空找回 B 与 C;可以在成本允许时扩大候选池,或使用检索系统的按文档分组能力,再评估是否改善。Qdrant 文档提供按字段分组搜索的机制;本文没有调用或压测该接口。

候选不足时可以明确返回少于 k 条,不应用重复块补齐数量。跨来源覆盖也不是越多越好:对同一事实的独立证据、互相冲突的版本和不可回答的问题,应分别在评测集中覆盖。

建议保存一小组真实问题及其应引用的原文位置,比较更改前后的证据命中、条件是否齐全、无关块占比、上下文长度,以及最终引用是否支持回答。实验里的选择器测试只证明代码遵守这些局部规则,回答质量需要另做评估。

资料与说明

Qdrant 分组搜索LangChain 递归切分

本文由 AI 辅助组织与撰写,10 项离线检查已实际运行。没有生产规模、模型准确率或性能提升数据。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值