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 条,不应用重复块补齐数量。跨来源覆盖也不是越多越好:对同一事实的独立证据、互相冲突的版本和不可回答的问题,应分别在评测集中覆盖。
建议保存一小组真实问题及其应引用的原文位置,比较更改前后的证据命中、条件是否齐全、无关块占比、上下文长度,以及最终引用是否支持回答。实验里的选择器测试只证明代码遵守这些局部规则,回答质量需要另做评估。
资料与说明
本文由 AI 辅助组织与撰写,10 项离线检查已实际运行。没有生产规模、模型准确率或性能提升数据。

308

被折叠的 条评论
为什么被折叠?



