Top-K 中的每篇文档都可能与问题相关,但整组结果仍可能反复表达同一件事,挤占有限的上下文窗口。本文用可运行的 Node.js 示例串起候选召回、近重复过滤、MMR 重排和集合级评测,同时观察相关性、主题覆盖率与冗余度。
单篇相关,不等于整组上下文有效
典型的 RAG 检索会计算查询与文档的相似度,然后直接取分数最高的 K 篇。这个过程优化的是单篇文档相关性,却没有显式约束文档之间的关系。
例如,用户询问“JWT 验证失败如何排查”,知识库里可能同时存在多篇关于 token 过期时间的相似说明。它们都很相关,因此容易占据 Top-K;但真正需要一起进入上下文的密钥轮换、缓存旧公钥、服务器时钟偏差等主题,反而可能被排除。
这类问题通常表现为:
- 排名前几篇内容相同,只是标题或措辞略有变化;
- 平均相似度很高,但答案缺少关键排查分支;
- 增大 K 后 token 消耗上升,新增信息却有限;
- 逐篇标注为“相关”时看不出问题,组合起来才发现冗余。
因此,检索结果不能只作为独立文档列表评估,还应被视为一个需要兼顾相关性与信息覆盖的集合。
从召回到上下文组装的三阶段流程
一个实用流程可以拆成三步:先召回比最终 K 更大的候选集,再过滤近重复内容,最后用 MMR 选择兼顾相关性与多样性的文档。
MMR 每轮选择下式得分最高的候选文档:λ·sim(query, doc) - (1-λ)·max sim(doc, selected)。第一项保留查询相关性,第二项惩罚与已选结果过于相似的文档。λ 越接近 1,行为越像普通 Top-K;λ 越小,越强调多样性。
| 阶段 | 主要目标 | 常用参数 | 风险 |
|---|---|---|---|
| 候选召回 | 尽量不漏掉相关内容 | candidateK | 太小会限制后续重排 |
| 近重复过滤 | 删除版本副本和轻微改写 | duplicateThreshold | 阈值过低会误删同主题互补内容 |
| MMR 重排 | 平衡相关性与差异性 | finalK、lambda | 过度追求多样性会引入弱相关结果 |
去重与 MMR 并不完全等价。近重复过滤适合处理几乎相同的文档,属于较强约束;MMR 则保留同一主题下可能互补的内容,只在选择顺序上施加软惩罚。实践中可以先做高置信度去重,再执行 MMR。
用 Node.js 实现召回、去重与 MMR
下面的程序不依赖第三方包,可保存为 rag-set-eval.mjs,使用 Node.js 18 或更新版本运行:node rag-set-eval.mjs。为了让示例完全可复现,候选召回采用 TF-IDF 余弦相似度;接入真实系统时,可以把向量生成和候选召回替换为已有的 embedding 与向量数据库,后续接口保持不变。
const docs = [
{ id: 'd1', text: 'JWT 验证 失败 时 检查 时钟 偏差 与 token 过期 时间', grade: 2, topics: ['时间'] },
{ id: 'd2', text: 'JWT 验证 失败 时 检查 时钟 偏差 与 token 过期 时间 设置', grade: 2, topics: ['时间'] },
{ id: 'd3', text: 'JWT 密钥 轮换 后 应刷新 JWKS 公钥 与 kid 映射', grade: 2, topics: ['密钥'] },
{ id: 'd4', text: '网关 缓存 旧 JWKS 会导致 JWT 签名 验证 失败', grade: 2, topics: ['缓存'] },
{ id: 'd5', text: '服务器 NTP 不同步 可能造成 token 提前 过期', grade: 1, topics: ['时间'] },
{ id: 'd6', text: 'Node.js 服务 使用 Docker 完成 部署', grade: 0, topics: [] },
{ id: 'd7', text: '签名 算法 不匹配 时 检查 JWT header 与服务端配置', grade: 1, topics: ['密钥'] }
];
const query = 'JWT 验证 失败 排查 过期 时钟 密钥 轮换 缓存';
const requiredTopics = ['时间', '密钥', '缓存'];
const tokenize = text => text.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean);
function buildIdf(corpus) {
const df = new Map();
for (const text of corpus) {
for (const term of new Set(tokenize(text))) {
df.set(term, (df.get(term) || 0) + 1);
}
}
const n = corpus.length;
return term => Math.log((n + 1) / ((df.get(term) || 0) + 1)) + 1;
}
function vectorize(text, idf) {
const counts = new Map();
for (const term of tokenize(text)) counts.set(term, (counts.get(term) || 0) + 1);
const vector = new Map();
let norm = 0;
for (const [term, count] of counts) {
const value = count * idf(term);
vector.set(term, value);
norm += value * value;
}
norm = Math.sqrt(norm) || 1;
for (const [term, value] of vector) vector.set(term, value / norm);
return vector;
}
function cosine(a, b) {
let sum = 0;
const [small, large] = a.size < b.size ? [a, b] : [b, a];
for (const [term, value] of small) sum += value * (large.get(term) || 0);
return sum;
}
function shingles(text, size = 3) {
const terms = tokenize(text);
if (terms.length < size) return new Set([terms.join(' ')]);
return new Set(terms.slice(0, terms.length - size + 1)
.map((_, index) => terms.slice(index, index + size).join(' ')));
}
function jaccard(a, b) {
const left = shingles(a);
const right = shingles(b);
let intersection = 0;
for (const value of left) if (right.has(value)) intersection++;
return intersection / (left.size + right.size - intersection || 1);
}
function recall(queryText, allDocs, candidateK) {
const idf = buildIdf(allDocs.map(doc => doc.text));
const queryVector = vectorize(queryText, idf);
const candidates = allDocs.map(doc => {
const vector = vectorize(doc.text, idf);
return { ...doc, vector, relevance: cosine(queryVector, vector) };
}).sort((a, b) => b.relevance - a.relevance).slice(0, candidateK);
return candidates;
}
function removeNearDuplicates(candidates, threshold) {
const kept = [];
for (const candidate of candidates) {
const duplicated = kept.some(item => jaccard(candidate.text, item.text) >= threshold);
if (!duplicated) kept.push(candidate);
}
return kept;
}
function mmr(candidates, k, lambda) {
const selected = [];
const remaining = [...candidates];
while (selected.length < k && remaining.length) {
let bestIndex = 0;
let bestScore = -Infinity;
for (let i = 0; i < remaining.length; i++) {
const candidate = remaining[i];
const redundancy = selected.length
? Math.max(...selected.map(item => cosine(candidate.vector, item.vector)))
: 0;
const score = lambda * candidate.relevance - (1 - lambda) * redundancy;
if (score > bestScore) {
bestScore = score;
bestIndex = i;
}
}
selected.push(remaining.splice(bestIndex, 1)[0]);
}
return selected;
}
function evaluate(items, allDocs, topics, k) {
const dcg = list => list.reduce((sum, item, index) =>
sum + (2 ** item.grade - 1) / Math.log2(index + 2), 0);
const ideal = [...allDocs].sort((a, b) => b.grade - a.grade).slice(0, k);
const covered = new Set(items.filter(item => item.grade > 0).flatMap(item => item.topics));
let pairSum = 0;
let pairCount = 0;
for (let i = 0; i < items.length; i++) {
for (let j = i + 1; j < items.length; j++) {
pairSum += jaccard(items[i].text, items[j].text);
pairCount++;
}
}
return {
nDCG: Number((dcg(items) / (dcg(ideal) || 1)).toFixed(3)),
coverage: Number((topics.filter(topic => covered.has(topic)).length / topics.length).toFixed(3)),
redundancy: Number((pairSum / (pairCount || 1)).toFixed(3))
};
}
const k = 4;
const candidates = recall(query, docs, 7);
const topK = candidates.slice(0, k);
const deduplicated = removeNearDuplicates(candidates, 0.7);
const diversified = mmr(deduplicated, k, 0.72);
console.table([
{ method: 'Top-K', ids: topK.map(item => item.id).join(','), ...evaluate(topK, docs, requiredTopics, k) },
{ method: 'Dedup+MMR', ids: diversified.map(item => item.id).join(','), ...evaluate(diversified, docs, requiredTopics, k) }
]);
示例中的 grade 和 topics 只用于离线评测,不应参与线上排序。否则就等于把标准答案泄漏给检索器。运行结果应以本地程序输出为准,也可以修改语料、λ 和阈值,观察三项指标如何变化。
用集合级指标衡量完整上下文
只看命中率仍然不够。上面的评测同时输出三类指标:
| 指标 | 回答的问题 | 方向 |
|---|---|---|
| nDCG@K | 高相关文档是否排在前面 | 越高越好 |
| Topic Coverage | 预先标注的关键主题覆盖了多少 | 越高越好 |
| Pairwise Redundancy | 结果两两之间平均有多相似 | 越低越好 |
nDCG 使用 0、1、2 级相关性标注,能够区分“可参考”和“直接解决问题”的文档。主题覆盖率则要求评测集为查询标注必要主题,例如时间、密钥和缓存;它不会因为同一主题命中三次就重复加分。
冗余度使用三词 shingle 的 Jaccard 相似度,适合识别文本近重复,但无法可靠识别语义改写。生产环境可以用文档 embedding 的余弦相似度补充语义冗余指标,不过阈值必须基于自己的文档长度、切块方式和模型校准,不能直接照搬示例参数。
评测时还应保留普通 Top-K 作为基线,并固定查询集、候选召回器和 K。只有这样,MMR 参数变化才是可比较的。不要只汇总全局平均值;按查询查看候选和最终结果,通常更容易定位是召回缺失、错误去重,还是多样性惩罚过强。
落地时需要注意的边界
候选召回决定了重排上限。某个必要主题如果没有进入候选集,MMR 无法凭空补回,因此应先确认候选集的主题召回,再调重排参数。候选集也不是越大越好:规模增加会提高向量比较和去重开销,应通过离线评测选择合适范围。
去重最好结合元数据。相同来源、相邻切块可能文本相似,却包含连续推理所需的信息;不同版本的文档也可能必须保留最新版,而不是简单保留召回分最高的一篇。可以先按文档版本、规范化 URL 或内容哈希做确定性去重,再使用文本相似度处理剩余近重复项。
MMR 也不必承担所有上下文组装工作。对于必须出现的产品版本、地区或权限条件,可以先做元数据过滤或分组配额,再在组内重排。线上还应记录候选分数、去重原因、MMR 每轮得分和最终文档 ID,使低质量回答能够追溯到具体阶段。
总结
- Top-K 优化单篇相关性,不能保证整组结果具有足够的信息覆盖;
- 先扩大候选召回,再进行高置信度近重复过滤,最后用 MMR 平衡相关性与多样性;
- nDCG、主题覆盖率和两两冗余度分别观察排序质量、信息完整性与上下文浪费;
candidateK、去重阈值和 λ 都应在固定评测集上调优,不存在适用于所有知识库的默认最优值;- 集合级指标不能替代人工检查,但能把“结果看起来都相关,答案却缺信息”的问题转化为可重复比较的工程过程。
评论