Appearance
检索后操作相关技术名词part1
1. Overview
这篇文章我们开始介绍 RAG 里面的检索后操作。在前面的文章里,我们已经介绍过检索前处理和检索操作。
检索前处理主要关心的是:用户的问题应该怎么改写、扩展、拆分,才能更适合拿去检索。
检索操作主要关心的是:系统应该通过向量检索、关键词检索、混合检索等方式,从知识库里召回哪些候选内容。
但是,检索器返回的 candidates,并不代表这些内容就可以直接塞进 prompt。常常会有这些问题:
- 有些内容只是主题相关,但不能真正回答问题
- 有些内容彼此重复,占用了大量上下文空间
- 有些内容虽然相关,但顺序并不适合生成答案
- 有些内容都来自同一个角度,缺少必要的信息覆盖
- 有些内容太长,需要压缩、截断或重新组织
- 有些内容需要保留来源信息,方便后面做引用和归因
因此在检索完成之后,通常还会有一段专门的处理链路,可以简单表示为:
ts
query -> retrieve candidates -> filtering -> ranking -> diversification -> context assembly -> generate后面的从 filtering 到 context assembly 这几步,都可以看作是 post-retrieval,因为它们发生在“检索器拿到候选结果之后,把上下文交给大模型生成答案之前”。其中:
- Filtering:先判断哪些 candidates 不应该进入上下文
- Ranking:再判断留下来的 candidates 谁更应该排在前面
- Diversification:再判断最终选择的内容是否覆盖了足够多的有效角度
- Context Assembly:最后把这些内容组织成适合大模型阅读的上下文
NOTE
需要先说明一点:这些环节不是说每个项目都要完整做一遍,也不是每个环节下面的每种方法都要用。
它们是一组可以按需组合的 post-retrieval 处理能力。实际项目里通常会按成本和风险分层启用:
- 先做低成本、确定性强的处理,例如 metadata、id、规则、token 长度判断
- 再做模型打分,例如 reranker / cross-encoder
- 最后只在必要场景里使用更贵的判断方式,例如 LLM 判断冲突、信息增量或复杂排序
所以读这篇文章的时候,不要理解成:
- 所有 filtering 都必须做
- 所有 ranking signals 都必须做
- 所有 diversification 方法都必须做
更合理的理解是:先知道每个环节解决什么问题,再根据业务风险、成本和效果选择合适的方法。
因为内容比较多,所以这里会拆成两篇文章来介绍。本篇文章先介绍前三个环节:
- Result Filtering
- Result Ranking
- Result Diversification
2. Result Filtering
Result Filtering,中文可以理解为“结果过滤”。它是 post-retrieval 链路里的第一步。检索器已经返回了一批 candidates,接下来要先判断这些 candidates 里面,哪些不应该继续往后传?
NOTE
这一步的过滤和检索前过滤不太一样。
检索前过滤通常是在检索之前缩小搜索范围,例如:
- 只搜索某个部门的文档
- 只搜索当前用户有权限访问的文档
- 只搜索
status = active的文档 - 只搜索某个时间范围内的文档
而 Result Filtering 回答的是:已经搜出来了,这些内容能不能继续用?
Result Filtering 可以用很多判断方式,例如:
- 用规则判断
- 用 metadata 判断
- 用 reranker 分数判断
- 用 cross-encoder 分数判断
- 用 LLM 判断
- 用安全检测模型判断
2-1. Relevance Filtering
Relevance Filtering 是相关性过滤。
它解决的问题是:检索结果虽然被召回了,但它是否真的能支持回答用户问题?
这里的“相关性”不是简单地看有没有共同关键词,也不是只看主题是否相近。而是关心这个 candidate 是否能对回答当前问题产生实际帮助?
举个例子,用户问:
txt
员工病假需要提供什么材料?检索结果里可能有这样一条:
txt
病假工资按照公司考勤制度进行计算。这条内容和“病假”这个主题确实相关,所以它可能会被向量检索召回。但它不能回答“需要提供什么材料”。它回答的是“病假工资怎么算”。所以从 Result Filtering 的角度来看,这条 candidate 应该被过滤掉。
这就是 Relevance Filtering 要解决的问题:语义上相似,不代表能够回答问题。在代码里可以抽象成这样:
ts
type Candidate = {
id: string;
content: string;
score?: number;
};
async function relevanceFilter(query: string, candidates: Candidate[]) {
const results: Candidate[] = [];
for (const candidate of candidates) {
const relevanceScore = await getRelevanceScore(query, candidate.content);
if (relevanceScore >= 0.7) {
results.push({
...candidate,
score: relevanceScore,
});
}
}
return results;
}这里的 getRelevanceScore 是一个抽象函数。它可以来自专门的 reranker,也可以来自 cross-encoder、LLM,或者业务规则。但在 Relevance Filtering 这一节,我们先不展开这些工具本身,只需要理解 Relevance Filtering 的本质即可。
2-2. Redundancy Filtering
Redundancy Filtering 是冗余过滤。它解决的问题是:检索结果里有些内容彼此重复,如果全部保留,会浪费上下文空间。
举个例子,用户问:
txt
病假超过 3 天需要什么材料?检索结果可能是:
txt
1. 病假超过 3 天,需要提供医院诊断证明。
2. 员工病假超过三天,应提交医院开具的诊断证明。
3. 病假 3 天以上,需要补充医院证明材料。
4. 年假需要提前 3 个工作日申请。前 3 条表达方式不同,但说的其实是同一件事。Redundancy Filtering 要做的,就是在进入后续 ranking 和 context assembly 之前,尽量只保留其中最有价值的一条。
那具体怎么判断重复呢?一般会按成本从低到高处理。
第一类是基于 id 或 metadata 去重。比如:
- 相同
chunkId:说明是同一个 chunk,直接去重 - 相同
sourceId + chunkHash:说明来自同一来源且文本一致,直接去重 - 相同
parentId:说明多个子 chunk 来自同一个父段落,可以只保留分数最高的一个
这种方式成本最低,也最稳定。示意代码如下:
ts
type Candidate = {
id: string;
content: string;
score: number;
metadata?: {
parentId?: string;
};
};
function redundancyFilter(candidates: Candidate[]) {
const seenParentIds = new Set<string>();
const results: Candidate[] = [];
const sorted = [...candidates].sort((a, b) => b.score - a.score);
for (const candidate of sorted) {
const parentId = candidate.metadata?.parentId;
if (parentId && seenParentIds.has(parentId)) {
continue;
}
if (parentId) {
seenParentIds.add(parentId);
}
results.push(candidate);
}
return results;
}第二类是基于文本相似度去重。
有时候两个 chunk 来自不同文档,但内容几乎一样。例如:
txt
A. 病假超过 3 天,需要提供医院诊断证明。
B. 员工病假超过三天,应提交医院开具的诊断证明。这类内容的 chunkId、sourceId、parentId 可能都不一样,但信息是重复的。
这时可以用文本 hash、编辑距离、n-gram overlap、SimHash / MinHash 这类方式判断。
第三类是基于 embedding 相似度去重。
如果两段文本表达方式不同,但语义高度相似,可以计算它们的 embedding cosine similarity。例如:
ts
type CandidateWithEmbedding = Candidate & {
embedding: number[];
};
function isSemanticDuplicate(a: CandidateWithEmbedding, b: CandidateWithEmbedding) {
return cosineSimilarity(a.embedding, b.embedding) >= 0.9;
}如果相似度超过阈值,就保留分数更高、内容更完整的一条。
至于要不要用大模型来做冗余判断,答案是:可以,但通常不是第一选择。
原因很简单:冗余过滤通常发生在候选结果比较多的时候,如果每两条都交给大模型判断,成本和延迟都会很高。
更常见的做法是:
- 先用 id / metadata 过滤明显重复
- 再用文本相似度或 embedding 相似度过滤近似重复
- 只有在少量高价值、难判断的候选内容里,才考虑用 LLM 判断两段内容是否表达同一事实
所以 Redundancy Filtering 本质上是在解决:不要把有限的上下文预算浪费在重复内容上。
2-3. Conflict Filtering
Conflict Filtering 是冲突过滤。它解决的问题是:候选结果之间出现了互相矛盾的信息,系统不能不加判断地全部保留。
举个例子,用户问:
txt
员工试用期可以申请年假吗?检索结果里可能同时出现:
txt
1. 试用期员工不能申请年假。
2. 入职满 6 个月的员工可以申请年假,试用期员工也适用。这两条内容都和问题相关。但它们不能直接同时作为答案依据。因为它们可能代表不同情况:
- 一条是旧制度,一条是新制度
- 一条是普通规则,一条是补充规则
- 一条是公司级制度,一条是部门级制度
- 一条是草稿,一条是正式生效版本
- 一条适用于正式员工,一条适用于试用期员工
如果系统把它们都塞进 prompt,大模型可能会生成一个看似折中的答案:
txt
试用期员工一般不能申请年假,但满足一定条件后可能可以申请。这个回答看起来合理,但它可能并不可靠。因此 Conflict Filtering 要做的事情:是在候选结果进入后续环节之前,先判断它们是否存在冲突,以及哪些内容更应该保留。
最简单的处理方式,是根据 metadata 判断权威性和时效性。例如:
ts
type Candidate = {
id: string;
content: string;
score: number;
metadata?: {
sourceType?: "company-policy" | "department-policy" | "draft";
effectiveAt?: string;
};
};
function getAuthority(candidate: Candidate) {
if (candidate.metadata?.sourceType === "company-policy") {
return 3;
}
if (candidate.metadata?.sourceType === "department-policy") {
return 2;
}
if (candidate.metadata?.sourceType === "draft") {
return 1;
}
return 0;
}
function sortByAuthority(candidates: Candidate[]) {
return [...candidates].sort((a, b) => {
const authorityDiff = getAuthority(b) - getAuthority(a);
if (authorityDiff !== 0) {
return authorityDiff;
}
return b.score - a.score;
});
}这段代码还没有真正理解冲突,只是演示一个常见策略:当候选结果存在冲突风险时,优先保留更权威的资料。
如果要进一步判断两段内容是否真的冲突,通常需要更强的判断方式。比如可以让模型判断:
ts
async function detectConflict(query: string, a: Candidate, b: Candidate) {
const result = await judge(`
用户问题:${query}
资料 A:${a.content}
资料 B:${b.content}
这两段资料在回答用户问题时是否存在冲突?
只返回 true 或 false。
`);
return result === "true";
}如果检测到冲突,系统一般有几种处理方式:
- 过滤掉低权威、低分数或旧版本的内容
- 保留冲突信息,但在后续生成阶段明确说明不确定性
- 触发追问,例如“你想查询哪个版本的制度?”
- 返回无法确定,而不是强行生成答案
所以 Conflict Filtering 本质上是在解决:当检索结果之间互相打架时,哪些内容可以继续使用。
2-4. Safety Filtering
Safety Filtering 是安全过滤。它解决的问题是:这段内容即使被检索到了,也是否适合进入 prompt。
它和 permission filtering 不完全一样。
- Permission Filtering 关注的是:当前用户有没有权限访问这份内容?
- Safety Filtering 关注的是:这段内容能不能安全地交给模型使用?
举个例子,某个用户确实有权限访问内部文档。但某个 chunk 里面包含:
txt
测试账号:admin
测试密码:123456或者包含:
txt
用户手机号、身份证号、内部密钥、未脱敏日志这些内容即使被检索到了,也不应该直接进入 prompt。因为一旦进入 prompt,大模型就可能在回答中泄露这些敏感信息。
Safety Filtering 通常会处理几类内容:
- 个人隐私信息
- 密钥、token、账号密码
- 内部调试日志
- 未脱敏数据
- 不适合对外输出的草稿内容
- 不符合当前回答策略的内容
处理方式一般有两种:
- 直接过滤:整段内容都不应该进入上下文
- 脱敏处理:内容可以使用,但敏感字段需要替换
示意代码如下:
ts
type Candidate = {
id: string;
content: string;
};
function maskSensitiveInfo(text: string) {
return text
.replace(/1[3-9]\d{9}/g, "[手机号已脱敏]")
.replace(/(api[_-]?key|token|password)\s*[:=]\s*\S+/gi, "$1=[已脱敏]");
}
function safetyFilter(candidates: Candidate[]) {
return candidates
.filter((candidate) => {
return !candidate.content.includes("内部草稿,请勿使用");
})
.map((candidate) => ({
...candidate,
content: maskSensitiveInfo(candidate.content),
}));
}这段代码只是为了说明思路。
真实项目里,Safety Filtering 通常不会只靠正则。它可能会接入:
- PII 检测
- 内容安全模型
- 企业内部合规规则
- 数据分级策略
- 输出策略控制
所以 Safety Filtering 本质上是在解决:这个 candidate 能不能安全地交给模型使用。
2-5. Context-fit Filtering
Context-fit Filtering 可以理解为“上下文适配过滤”。它解决的问题是:有些内容并不是完全无关,也没有安全问题,但它不值得占用上下文窗口。
举个例子,检索结果里可能有这样的 chunk:
txt
目录
第一章 总则
第二章 请假管理
第三章 薪酬管理
第四章 附则它里面确实出现了“请假管理”,所以可能被召回。
但如果用户问的是:
txt
病假需要提供什么材料?这个目录信息几乎没有帮助。
再比如:
txt
版权所有,未经许可不得转载。或者:
txt
本制度最终解释权归公司所有。这些内容可能来自正式文档,也可能和当前主题略有关联,但它们不值得占用 prompt 空间。
Context-fit Filtering 通常会过滤:
- 目录
- 页眉页脚
- 版权声明
- 信息量很低的片段
- 只有关键词但没有有效内容的 chunk
- 太长但和问题只有弱相关的 chunk
示意代码如下:
ts
type Candidate = {
id: string;
content: string;
score: number;
};
function isLowValueContent(content: string) {
const patterns = [
/目录/,
/版权所有/,
/最终解释权/,
/第[一二三四五六七八九十]+章/,
];
return patterns.some((pattern) => pattern.test(content));
}
function contextFitFilter(candidates: Candidate[]) {
return candidates.filter((candidate) => {
const content = candidate.content.trim();
if (content.length < 20) {
return false;
}
if (isLowValueContent(content)) {
return false;
}
return true;
});
}真实项目里,Context-fit Filtering 通常会结合更多因素:
- token 数
- chunk 类型
- 信息密度
- 相关性分数
- 是否包含可用于回答的事实
比如可以设计一个简单的上下文价值分:
ts
function getContextValue(candidate: Candidate) {
const lengthPenalty = candidate.content.length > 1500 ? 0.2 : 0;
const lowValuePenalty = isLowValueContent(candidate.content) ? 0.5 : 0;
return candidate.score - lengthPenalty - lowValuePenalty;
}
const filtered = candidates.filter((candidate) => {
return getContextValue(candidate) >= 0.5;
});总结一下,Result Filtering 是 post-retrieval 里的第一道筛选。它不负责决定最终顺序,也不负责组织上下文。
它只回答一个问题:这个 candidate 要不要继续往后传?
经过 Result Filtering 之后,系统得到的是一批更干净的候选结果。
但这些结果还没有排好顺序。下一步要解决的是:留下来的 candidates 里面,谁更重要?谁应该排在前面?
这就是下一章要介绍的 Result Ranking。
3. Result Ranking
Result Ranking,中文可以理解为“结果排序”。它是 post-retrieval 链路里的第二步。它要回答的问题是:留下来的 candidates 里面,谁更重要?谁应该排在前面?
举个例子,用户问:
txt
员工病假超过 3 天需要提供什么材料?经过 filtering 之后,系统可能留下下面几条内容:
txt
1. 病假工资按照公司考勤制度计算。
2. 病假超过 3 天,需要提供医院诊断证明。
3. 员工申请病假,应提前在系统中提交申请。这几条都和“病假”有关,也都可能有一定价值。但从回答问题的角度看,第 2 条明显应该排在最前面。因为用户问的是“需要提供什么材料”,不是“病假工资怎么算”,也不是“怎么提交申请”。
Result Ranking 可以分成两层来看:
- Re-rank / Reranker:具体的重排序动作和实现方式
- Ranking Signals:排序时可以参考的信号或特征
3-1. Re-rank / Reranker
Re-rank 解决的问题是:第一次检索返回的顺序不一定可靠,需要再排一次。
第一次检索通常更重视召回效率。比如向量检索会根据 embedding 相似度返回 topK 结果,关键词检索会根据 BM25 之类的分数返回结果。这些分数有用,但它们不一定能精确表示“这条 chunk 对当前问题的回答价值”。
举个例子:
txt
用户问题:员工病假超过 3 天需要提供什么材料?
原始检索结果:
1. 病假工资按照公司考勤制度计算。retrievalScore = 0.86
2. 病假超过 3 天,需要提供医院诊断证明。retrievalScore = 0.84
3. 员工申请病假,应提前在系统中提交申请。retrievalScore = 0.82如果只看原始检索分数,第 1 条排在最前面。但真正更适合回答问题的是第 2 条。
Re-rank 会重新计算 query 和 candidate 的匹配程度,然后得到新的排序:
txt
重排序结果:
1. 病假超过 3 天,需要提供医院诊断证明。rerankScore = 0.93
2. 员工申请病假,应提前在系统中提交申请。rerankScore = 0.61
3. 病假工资按照公司考勤制度计算。rerankScore = 0.32这里的关键是 rerankScore。它通常不是简单的向量相似度,而是通过更精细的 query-document matching 得到的分数。
常见的 Re-rank 实现方式是使用 reranker model。这是一个专门的“相关性打分器”。它通常不是通用聊天模型,而是经过 query-document matching、passage ranking 这类任务训练或微调的模型。
例如 Cross-Encoder 就是 reranker model 里很常见的一种实现方式。它会把 query 和 chunk 放在一起让模型直接判断它们的匹配程度。
比如:
txt
query: 病假超过 3 天需要什么材料?
chunk: 病假超过 3 天,需要提供医院诊断证明。
score = 0.93再看另一条:
txt
query: 病假超过 3 天需要什么材料?
chunk: 病假工资按照公司考勤制度计算。
score = 0.34这时模型就能更清楚地判断:第二条虽然和“病假”相关,但它没有回答“材料”这个问题。
因为 cross-encoder 要把每一个 query 和 candidate 组合起来跑一次模型,所以它通常不会直接面对全量文档,而是放在召回之后:
ts
query -> retrieve top 50 -> reranker / cross-encoder rerank -> top 5也就是说:
- 第一阶段先用向量检索、关键词检索、混合检索快速召回 candidates
- 第二阶段再用 reranker / cross-encoder 对 candidates 做更精细的打分和排序
3-2. Ranking Signals
Ranking Signals 可以理解为“排序信号”。它解决的是:系统在排序 candidates 时,除了 reranker 分数之外,还可以参考哪些因素。
前面说的 reranker 更像是一个打分工具。但真实项目里的排序不一定只看一个分数。有时候还会结合其他信号,例如:
- candidate 是否对准用户真正问的点
- candidate 是否满足用户指定的版本、时间、条件
- candidate 是否来自更新的文档
- candidate 是否来自更权威的来源
这些都可以作为 ranking signals。下面看两个常见的排序信号。
3-2-1. Query-Document Alignment
Query-Document Alignment 可以理解为“查询和文档的对齐程度”。它解决的是:一个 candidate 和当前 query 到底匹配到什么程度。
这里的“对齐”不是简单看有没有共同关键词,也不是只判断它们是不是同一个大主题,而是看 candidate 是否对准了 query 里的关键意图、对象、条件和约束。
所以它本质上更接近一个 scoring problem,也就是打分问题。它通常会输出一个分数,例如:
txt
alignmentScore = 0.92这个分数可以被后续的排序、过滤、上下文选择使用。
举个例子,用户问:
txt
病假超过 3 天需要什么材料?候选内容有:
txt
A. 病假超过 3 天,需要提供医院诊断证明。
B. 员工病假期间的工资按照公司考勤制度计算。
C. 员工申请病假时,应在系统中选择病假类型,并填写请假开始时间和结束时间。这三条都和“病假”有关。如果只看主题,它们都算相关。
但用户真正问的是:
txt
对象:病假
条件:超过 3 天
信息需求:需要什么材料所以这三条的 Query-Document Alignment 不一样:
| 候选内容 | 对齐情况 | alignmentScore 示例 |
|---|---|---|
| A | 对准了“病假 + 超过 3 天 + 材料” | 0.95 |
| B | 只对准了“病假”,但回答的是“工资怎么算” | 0.35 |
| C | 只对准了“病假申请”,但回答的是“怎么申请” | 0.45 |
这里 A 的分数最高,不是因为它包含更多关键词,而是因为它回答了用户真正需要的那部分信息。
再看一个版本类问题:
txt
SDK v2.3.0 的默认 timeout 是多久?候选内容有:
txt
A. SDK 默认 timeout 是 5 秒。
B. 从 SDK v2.3.0 开始,默认 timeout 调整为 10 秒。
C. SDK v1.0 的默认 timeout 是 3 秒。如果只看“SDK、默认、timeout”,A、B、C 都有一定相关性。
但用户明确问的是 v2.3.0,所以 B 的 alignment 更高。A 缺少版本条件,C 对准了 timeout,但版本条件是错的。
因此 Query-Document Alignment 重点看这些部分是否对应得上:
- 用户问的对象是什么
- 用户问的条件是什么
- 用户问的信息需求是什么
- candidate 回答的对象、条件和信息需求是否一致
- candidate 是否违反了 query 里的版本、时间、范围等约束
常见实现方式包括:
- 使用 reranker model 或 cross-encoder 给 query-document pair 打分
- 使用 LLM 根据 query 和 candidate 生成匹配分数
- 使用 query analysis 抽取关键约束,再判断 candidate 是否覆盖这些约束
- 融合 BM25、embedding similarity、metadata、版本、时间等特征
示意代码如下:
ts
type Document = {
id: string;
content: string;
metadata?: {
version?: string;
updatedAt?: string;
};
};
type ScoredDocument = Document & {
alignmentScore: number;
};
interface AlignmentModel {
score(query: string, doc: Document): number;
}如果用一个非常简化的规则来表达,可能是这样:
ts
type QueryIntent = {
topic: string;
need: string;
constraints?: {
version?: string;
condition?: string;
};
};
function getAlignmentScore(intent: QueryIntent, doc: Document) {
let score = 0;
if (doc.content.includes(intent.topic)) {
score += 0.3;
}
if (doc.content.includes(intent.need)) {
score += 0.3;
}
if (
intent.constraints?.condition &&
doc.content.includes(intent.constraints.condition)
) {
score += 0.2;
}
if (
intent.constraints?.version &&
doc.content.includes(intent.constraints.version)
) {
score += 0.2;
}
if (
intent.constraints?.version &&
doc.content.includes("v1.0") &&
intent.constraints.version !== "v1.0"
) {
score -= 0.4;
}
return Math.max(0, Math.min(score, 1));
}这段代码只是为了说明思路,真实项目里通常不会只靠 includes。更常见的是用模型打分,或者把模型分数和规则特征融合起来。
比如:
ts
function getFinalRankScore(retrievalScore: number, alignmentScore: number) {
return retrievalScore * 0.5 + alignmentScore * 0.5;
}这里的重点不是具体权重,而是:alignment score 可以作为 ranking signal 参与最终排序。
Query-Document Alignment 很容易和 Relevance Filtering 混在一起,所以这里需要专门区分一下。
Query-Document Alignment 关注的是:
txt
这个 candidate 和 query 对齐得有多好?它输出的是分数。
Relevance Filtering 关注的是:
txt
这个 candidate 要不要留下?它输出的是保留或丢弃。
可以用一张表来区分:
| 维度 | Query-Document Alignment | Relevance Filtering |
|---|---|---|
| 类型 | 打分,scoring | 筛选,selection |
| 输入 | query + candidate | candidates、score、metadata、规则 |
| 输出 | alignment score | 保留后的 subset |
| 关注点 | 有多对齐 | 要不要留下 |
| 常见实现 | cross-encoder、LLM scoring、特征融合 | 阈值、Top-K、metadata filter、规则过滤 |
| 在排序里的作用 | 提供排序信号 | 控制候选结果规模和质量 |
它们的关系可以理解为:
txt
Alignment -> 提供分数
Rerank -> 使用分数排序
Filtering -> 使用分数或规则裁剪对应到工程接口,可以这样拆:
ts
interface AlignmentModel {
score(query: string, doc: Document): number;
}
interface Filter {
keep(doc: Document, score: number): boolean;
}
interface Reranker {
rank(docs: ScoredDocument[]): ScoredDocument[];
}比如在一个 post-retrieval pipeline 里:
ts
function postRetrieve(query: string, candidates: Document[]) {
const scoredDocs = candidates.map((doc) => ({
...doc,
alignmentScore: alignmentModel.score(query, doc),
}));
const filteredDocs = scoredDocs.filter((doc) => doc.alignmentScore >= 0.5);
return reranker.rank(filteredDocs).slice(0, 5);
}在这个例子里,Alignment 负责给每个 candidate 打分,Filtering 用分数做裁剪,Reranker 再根据分数或其他信号重新排序。
实际系统中,这几个步骤的顺序不一定固定。有些系统会先做规则过滤,再做 alignment scoring;有些系统会先 rerank,再用阈值过滤低分结果。
但职责边界是清楚的:
- Query-Document Alignment 解决“相关性和约束对齐如何度量”
- Relevance Filtering 解决“哪些 candidate 应该被保留”
- Rerank 解决“留下来的 candidate 谁应该排在前面”
所以 Query-Document Alignment 本质上是一种更细粒度的相关性打分信号:它不只是判断 candidate 是否和 query 属于同一主题,而是判断 candidate 是否对准了 query 里的关键意图、条件和约束。
3-2-2. Recency Bias
Recency Bias 可以理解为“新近性偏置”。它关注的是:当多个候选内容都相关时,是否应该让更新、更可能有效的资料排在前面。这在政策、制度、产品文档、公告类知识库里非常常见。
举个例子,用户问:
txt
2026 年的报销标准是多少?候选结果有:
txt
A. 2024 年差旅报销标准:一线城市住宿上限为 500 元。
B. 2026 年差旅报销标准:一线城市住宿上限为 650 元。
C. 2025 年差旅报销标准:一线城市住宿上限为 600 元。这三条都和“报销标准”有关,但 B 明显更应该排在前面。
再比如用户问:
txt
SDK 默认 timeout 是多久?候选结果有:
txt
A. 2023 年文档:SDK 默认 timeout 是 5 秒。
B. 2026 年文档:SDK 默认 timeout 是 10 秒。如果用户没有明确说要看旧版本,通常应该优先使用更新的资料。
示意代码如下:
ts
type CandidateWithDate = {
id: string;
content: string;
relevanceScore: number;
metadata?: {
updatedAt?: string;
};
};
function getRecencyScore(updatedAt?: string) {
if (!updatedAt) {
return 0;
}
const updatedTime = new Date(updatedAt).getTime();
const now = Date.now();
const ageInDays = (now - updatedTime) / (1000 * 60 * 60 * 24);
if (ageInDays <= 30) {
return 1;
}
if (ageInDays <= 180) {
return 0.7;
}
if (ageInDays <= 365) {
return 0.4;
}
return 0.1;
}
function rankWithRecency(candidates: CandidateWithDate[]) {
return [...candidates].sort((a, b) => {
const scoreA =
a.relevanceScore * 0.8 + getRecencyScore(a.metadata?.updatedAt) * 0.2;
const scoreB =
b.relevanceScore * 0.8 + getRecencyScore(b.metadata?.updatedAt) * 0.2;
return scoreB - scoreA;
});
}这里的 0.8 和 0.2 只是示意,表示相关性仍然是主要因素,新近性只是一个加权项。
Recency Bias 不是说越新的内容一定越正确。它只是说:在其他条件差不多的情况下,更新的内容通常更值得优先使用。
如果一条新内容只是弱相关,而一条旧内容能直接回答问题,就不能简单地因为“新”而把它排在最前面。
比如:
txt
用户问题:病假超过 3 天需要什么材料?
A. 2026 年更新:病假工资按照公司考勤制度计算。
B. 2024 年制度:病假超过 3 天,需要提供医院诊断证明。虽然 A 更新,但 B 更直接回答问题,所以更合理的排序仍然应该让 B 在前面。
另外,如果用户明确指定了版本或时间,也要优先满足用户约束。例如用户问:
txt
SDK v1.0 的默认 timeout 是多久?这时即使 v2.3.0 的文档更新,也不能把 v2.3.0 的内容排在最前面。
所以 Recency Bias 本质上是一种排序信号:在相关性和条件都接近时,让更新、更符合当前语境的内容获得更高排序。
总结一下,Result Ranking 是 post-retrieval 里的排序环节。
它只回答一个问题:留下来的 candidates 里面,谁更应该排在前面?
Result Ranking 可以拆成两层:
| 类型 | 解决的问题 |
|---|---|
| Re-rank / Reranker | 用更精细的 query-document 判断重新排序 |
| Ranking Signals | 排序时额外参考哪些信号或特征 |
常见的 Ranking Signals 包括:
| 信号 | 作用 |
|---|---|
| Query-Document Alignment | 判断 candidate 是否对准用户真正问的点 |
| Recency Bias | 在相关性接近时优先使用更新、更有效的资料 |
经过 Result Ranking 之后,系统得到的是一个更合理的候选结果顺序。
但排序还有一个问题:排在前面的内容可能都来自同一个角度。
比如前几条全部都在讲“病假材料”,但没有一条讲“补交流程”或“审批要求”。
这就需要下一章的 Result Diversification 来处理。
4. Result Diversification
Result Diversification,中文可以理解为“结果多样化”。它是 post-retrieval 链路里的第三步。
经过 Result Filtering 和 Result Ranking 之后,系统已经得到了一批更干净、顺序也更合理的 candidates。
但这里还有一个问题:排在前面的结果,可能都来自同一个角度。
举个例子,用户问:
txt
病假超过 3 天需要注意什么?经过排序之后,系统可能得到:
txt
1. 病假超过 3 天,需要提供医院诊断证明。
2. 病假超过 3 天,需要在系统上传诊断证明。
3. 病假超过 3 天,诊断证明需包含医生签名和医院盖章。
4. 如遇紧急情况,员工应在事后 24 小时内补交病假申请。
5. 病假申请需要直属负责人审批。如果只看相关性,前 3 条可能都很高,并且这几条也不完全重复,甚至每条都有一点新增信息。
不过它们都集中在“诊断证明”这个角度。也就是说,它们其实都在讲同一件事:超过 3 天需要诊断证明。
如果最终上下文只放前 3 条,大模型就会反复看到同一个信息,却看不到“补交流程”和“审批要求”。
所以 Result Diversification 要回答的问题是:在相关性足够的 candidates 里面,如何避免最终结果过于单一?
它不是不看相关性,而是在相关性足够的前提下,让最终进入上下文的内容覆盖更多有价值的角度。
Result Diversification 还有另一个称呼:Diversity Optimization
4-1. MMR
MMR 是 Maximal Marginal Relevance 的缩写,可以理解为“最大边际相关性”。它是结果多样化里很经典的一种方法。
MMR 的核心思路是:每次选择下一个 candidate 时,同时考虑相关性和边际增益。
这里的边际增益指的是:这个 candidate 被加入上下文之后,能不能提供和已选内容不一样的信息。
所以 MMR 不是一次性把所有结果排好,而是一个迭代选择过程:
- 先选择一个和 query 最相关的 candidate
- 再选择下一个 candidate 时,惩罚它和已选内容的相似度
- 重复这个过程,直到选够需要的数量
举个例子,用户问:
txt
病假超过 3 天需要注意什么?假设第一轮已经选中了:
txt
A. 病假超过 3 天,需要提供医院诊断证明。第二轮要在下面几个候选里继续选:
| candidate | 内容 | relevanceScore | 和 A 的相似度 |
|---|---|---|---|
| B | 病假超过 3 天,需要在系统上传诊断证明。 | 0.91 | 0.88 |
| C | 病假超过 3 天,诊断证明需包含医生签名和医院盖章。 | 0.88 | 0.82 |
| D | 如遇紧急情况,应在事后 24 小时内补交病假申请。 | 0.76 | 0.32 |
| E | 病假申请需要直属负责人审批。 | 0.71 | 0.28 |
如果只看 relevanceScore,B 可能会被选中。
但 MMR 会同时看到:
- B 和 A 的相似度很高,加入上下文后的新增价值有限;
- D 虽然相关性低一点,但它补充了“补交流程”这个新角度。
所以第二轮 MMR 可能会选择 D,而不是 B。
MMR 的简化公式可以理解为:
txt
MMR score = relevanceScore - similarityPenalty更完整一点可以写成:
txt
MMR score = lambda * relevanceScore - (1 - lambda) * maxSimilarityToSelected其中:
relevanceScore表示 candidate 和 query 的相关性maxSimilarityToSelected表示 candidate 和已选结果里最相似的一条有多像lambda用来控制相关性和多样性的权重
示意代码如下:
ts
type Candidate = {
id: string;
content: string;
relevanceScore: number;
embedding: number[];
};
function cosineSimilarity(a: number[], b: number[]) {
return similarity(a, b);
}
function mmrSelect(candidates: Candidate[], limit: number, lambda = 0.7) {
const selected: Candidate[] = [];
const remaining = [...candidates];
while (selected.length < limit && remaining.length > 0) {
let bestCandidate: Candidate | null = null;
let bestScore = -Infinity;
for (const candidate of remaining) {
const maxSimilarityToSelected =
selected.length === 0
? 0
: Math.max(
...selected.map((item) => {
return cosineSimilarity(candidate.embedding, item.embedding);
})
);
const mmrScore =
lambda * candidate.relevanceScore -
(1 - lambda) * maxSimilarityToSelected;
if (mmrScore > bestScore) {
bestScore = mmrScore;
bestCandidate = candidate;
}
}
if (!bestCandidate) {
break;
}
selected.push(bestCandidate);
const index = remaining.findIndex((item) => item.id === bestCandidate?.id);
remaining.splice(index, 1);
}
return selected;
}lambda越接近 1,越重视相关性lambda越接近 0,越重视多样性
在 RAG 场景里,通常不能过度追求多样性。因为如果多样性权重太高,系统可能会选入一些“虽然不重复,但和问题关系不大”的内容。
所以 MMR 本质上是在做一种平衡:优先选择相关内容,但当多个内容都相关时,让信息增量更高的 candidate 有机会进入上下文。
4-2. Topic / Source Coverage
Topic / Source Coverage 可以理解为“主题和来源覆盖”。
它解决的问题是:最终选出来的 candidates 是否覆盖了足够多的主题、来源或信息类型。
MMR 更偏算法,它通过“相关性”和“相似度惩罚”来做选择。Coverage 更偏策略,它会显式控制最终结果的覆盖范围。
举个例子,用户问:
txt
公司报销流程是怎样的?一个好的上下文可能需要覆盖这些方面:
- 报销申请入口
- 发票要求
- 审批流程
- 打款时间
- 特殊情况处理
如果排序结果前 5 条全部都在讲“发票要求”,那它们虽然相关,但不够全面。
更好的结果应该类似这样:
txt
1. 报销申请需要在系统中提交。
2. 报销单需要上传合规发票。
3. 部门负责人审批后进入财务审核。
4. 审核通过后,费用将在 5 个工作日内打款。
5. 如发票遗失,需要提交情况说明。这就是一种覆盖策略:不要让结果全部集中在“发票要求”上,而是让它覆盖报销流程里的不同环节。
Coverage 常见的做法有:
- 按主题分组后,每组选择一部分结果
- 按 source、section、parentId 控制同源内容数量
- 按信息类型做覆盖,例如规则、流程、限制、例外
- 对相似内容做惩罚,让不同角度的内容有机会进入上下文
示意代码如下:
ts
type Candidate = {
id: string;
content: string;
score: number;
metadata?: {
topic?: string;
};
};
function diversifyByTopic(candidates: Candidate[], limit: number) {
const groups = new Map<string, Candidate[]>();
for (const candidate of candidates) {
const topic = candidate.metadata?.topic ?? "unknown";
const group = groups.get(topic) ?? [];
group.push(candidate);
groups.set(topic, group);
}
for (const group of groups.values()) {
group.sort((a, b) => b.score - a.score);
}
const results: Candidate[] = [];
while (results.length < limit) {
let added = false;
for (const group of groups.values()) {
const candidate = group.shift();
if (!candidate) {
continue;
}
results.push(candidate);
added = true;
if (results.length >= limit) {
break;
}
}
if (!added) {
break;
}
}
return results;
}这段代码的思路是:先按 topic 分组,然后从不同 topic 里面轮流取结果,这样可以避免一个 topic 把上下文窗口全部占满。当然,真实项目里的 topic 不一定是 metadata 里直接给好的。它可能来自:
- 文档结构,例如标题、章节、目录
- indexing 阶段生成的分类标签
- query analysis 提取出来的信息需求
- 模型对候选结果做的主题归类
除了 topic,还可以按 source 做覆盖。比如检索结果前几条都来自同一份文档,如果全部保留,可能会让上下文过度依赖单一来源。这时可以限制同一个 source 最多进入几条:
ts
type CandidateWithSource = Candidate & {
metadata?: {
sourceId?: string;
};
};
function limitPerSource(candidates: CandidateWithSource[], maxPerSource = 2) {
const sourceCount = new Map<string, number>();
const results: CandidateWithSource[] = [];
for (const candidate of candidates) {
const sourceId = candidate.metadata?.sourceId ?? "unknown";
const count = sourceCount.get(sourceId) ?? 0;
if (count >= maxPerSource) {
continue;
}
sourceCount.set(sourceId, count + 1);
results.push(candidate);
}
return results;
}这种做法适合那些资料来源比较多的知识库。
比如:
- 公司制度库
- 产品文档库
- 帮助中心
- FAQ
- 历史公告
如果一个问题需要综合多个来源,Coverage 可以避免最终上下文只来自一个文档或一个章节。
不过 Coverage 也要注意一个前提:多样性不能压过相关性。
比如用户问:
txt
病假超过 3 天需要什么材料?如果系统为了多样化,把“年假审批流程”“差旅报销规则”也选进来,那就是错误的。
所以多样化的前提通常是:先保证候选结果和 query 有足够相关性,再在相关结果里面做多样化。
4-3. Near-duplicate Removal
Near-duplicate Removal 是“近重复移除”。
它解决的问题是:文本不完全重复,但信息高度重复时,如何避免上下文浪费。
放在 Result Diversification 里讲 Near-duplicate Removal,重点不是再重复一次前面的去重逻辑,而是强调最终上下文选择时的一个问题:
txt
这条 candidate 虽然和已选内容很像,但它有没有新的信息增量?如果没有信息增量,就可以移除。
如果有新的条件、步骤、例外或限制,就不应该简单移除。
举个例子:
txt
A. 病假超过 3 天,需要提供医院诊断证明。
B. 病假超过 3 天,需要在系统上传诊断证明。
C. 病假超过 3 天,诊断证明需包含医生签名和医院盖章。
D. 病假超过 3 天,如遇紧急情况,可在事后 24 小时内补交证明。这几条内容都很像,embedding 相似度可能也都很高。
但它们的信息增量不一样:
| candidate | 是否应该直接当近重复删除 | 原因 |
|---|---|---|
| B | 不一定 | 它补充了“系统上传”这个提交方式 |
| C | 不一定 | 它补充了“医生签名和医院盖章”这个材料要求 |
| D | 不应该 | 它补充了“紧急情况”和“24 小时内补交”这个例外规则 |
如果系统只用一个很粗的相似度阈值,例如 similarity >= 0.85 就删除,那么 B、C、D 都可能被误删。
这会让最终上下文只剩下“需要诊断证明”,却丢掉“怎么提交”“材料格式”“能否补交”这些更细的信息。
Near-duplicate Removal 常见的做法有:
- 文本相似度判断
- embedding 相似度判断
- MinHash / SimHash 这类近似去重算法
- 判断两段内容是否表达同一事实
- 判断 candidate 是否相对已选内容有新的信息增量
用 embedding 相似度做一个简化示意:
ts
type Candidate = {
id: string;
content: string;
score: number;
embedding: number[];
facts?: string[];
};
function hasNewFact(candidate: Candidate, selected: Candidate[]) {
const selectedFacts = new Set(selected.flatMap((item) => item.facts ?? []));
return (candidate.facts ?? []).some((fact) => {
return !selectedFacts.has(fact);
});
}
function removeNearDuplicates(candidates: Candidate[], threshold = 0.9) {
const results: Candidate[] = [];
const sorted = [...candidates].sort((a, b) => b.score - a.score);
for (const candidate of sorted) {
const isDuplicate = results.some((item) => {
const similarityScore = cosineSimilarity(candidate.embedding, item.embedding);
return similarityScore >= threshold;
});
if (isDuplicate && !hasNewFact(candidate, results)) {
continue;
}
results.push(candidate);
}
return results;
}这段代码的意思是:
- 先按分数排序
- 优先保留高分内容
- 后续如果遇到和已保留内容非常相似、且没有新信息的 candidate,就跳过
- 如果 candidate 虽然相似,但包含新的事实、条件、步骤或例外,就保留
这里的 threshold = 0.9 只是示意。
阈值太高,可能漏掉很多近重复内容。
阈值太低,又可能把本来有差异的信息误删。
比如下面两条就不应该简单按近重复处理:
txt
A. 病假超过 3 天,需要提供医院诊断证明。
B. 病假超过 3 天,如遇紧急情况,可在事后 24 小时内补交证明。A 和 B 很相似,但 B 多了一个“紧急情况补交”的例外规则。
如果系统把 B 当成重复内容删掉,最终答案就会少掉重要信息。
所以 Near-duplicate Removal 不能只看相似度,还要看信息增量。
可以简单理解为:
txt
如果两条内容表达的是同一个事实,删除低分的;
如果两条内容相似但包含不同条件、例外、步骤,就尽量保留。所以 Near-duplicate Removal 本质上是在解决:既要减少重复,又不能误删有信息增量的内容。
总结一下,Result Diversification 是 post-retrieval 里的多样化环节。
它只回答一个问题:最终选择出来的 candidates,是否覆盖了足够多的有效角度?
常见的 Result Diversification 可以总结为:
| 类型 | 解决的问题 |
|---|---|
| MMR | 在相关性和多样性之间做平衡 |
| Topic / Source Coverage | 控制主题、来源、章节或信息类型的覆盖 |
| Near-duplicate Removal | 去掉表达不同但信息高度重复的内容 |
5. Final
到这里,本篇文章介绍的三个 post-retrieval 环节就完整了:
- Result Filtering:决定哪些 candidates 可以继续往后传
- Result Ranking:决定留下来的 candidates 谁更应该排在前面
- Result Diversification:决定最终选择的 candidates 是否足够丰富
这三步完成之后,系统得到的是一批质量更高、顺序更合理、内容也更均衡的候选资料。
不过在真实项目里,通常不会一上来就把所有方法全部做一遍。
一个比较常见的企业级默认链路是:
txt
retrieve topN
-> metadata / permission / safety check
-> redundancy filtering
-> reranker rerank
-> topK selection
-> optional diversification
-> context assembly也就是说:
- Filtering 里,权限、安全、明显重复、低价值内容通常更常见
- Ranking 里,reranker / cross-encoder 是比较常见的增强手段
- Diversification 不一定每个项目都做,通常在结果重复、来源单一、问题需要多角度回答时再启用
如果是普通知识库问答,可能只需要:
txt
retrieve -> basic filtering -> rerank -> topK -> context assembly如果是制度、法务、医疗、金融这类高风险场景,就可能需要加上:
txt
conflict filtering -> authority / recency signals -> source attribution所以这些技术名词更像是一套工具箱。真正落地时,不是把所有工具都用上,而是根据业务问题选择合适的组合。
另外,这些资料还不能直接全部塞进 prompt。因为大模型有上下文窗口限制,而且 prompt 里面不只有检索资料,还可能包括 system prompt、用户问题、历史对话、工具调用结果等内容。
所以接下来还需要进入下一个环节:Context Assembly。
Context Assembly,中文可以理解为“上下文组装”。
它要解决的是:这些已经筛选、排序、多样化之后的 candidates,最终应该怎么组织成大模型可以使用的上下文?
这一部分通常会包含两个方向。
第一个方向是 Context Window Management。它关注的是上下文窗口怎么分配,例如:
- 最终放多少条 candidates
- 每条内容最多占多少 token
- 是否需要压缩或截断 chunk
- 长上下文里重要内容应该放在什么位置
第二个方向是 Evidence Organization。它关注的是证据怎么组织,例如:
- 是否按主题分组
- 是否合并来自同一来源的内容
- 是否保留 source id
- 是否保留来源标题、章节、页码或 URL
- 是否方便后续生成阶段做引用和追溯
这两个方向都会放到下一篇文章,也就是《检索后操作相关技术名词 part2》里面继续介绍。
本篇文章先到这里,再见👋
-EOF-