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) }
]);

示例中的 gradetopics 只用于离线评测,不应参与线上排序。否则就等于把标准答案泄漏给检索器。运行结果应以本地程序输出为准,也可以修改语料、λ 和阈值,观察三项指标如何变化。

用集合级指标衡量完整上下文

只看命中率仍然不够。上面的评测同时输出三类指标:

指标回答的问题方向
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、去重阈值和 λ 都应在固定评测集上调优,不存在适用于所有知识库的默认最优值;
  • 集合级指标不能替代人工检查,但能把“结果看起来都相关,答案却缺信息”的问题转化为可重复比较的工程过程。