📖 本章学习目标
- 解释稠密检索失败时稀疏检索(BM25)如何提供互补价值
- 实现向量搜索与 BM25 的混合检索,并通过 RRF 进行结果融合
- 使用 Multi-Query Retriever 从多个角度检索同一问题
- 运用 MMR 算法和 Metadata Filter 提升检索结果的多样性和精确度
在《第8章:RAG 的局限性及应对策略》中,我们系统梳理了 RAG 的局限,其中"检索失败"被列为首要瓶颈,语义鸿沟、关键词匹配失败、Top-K 结果同质化都是常见问题。从本章开始,进阶篇将逐一提供这些问题的解决方案。
检索策略是 RAG 系统可调节的最重要杠杆,是决定答案质量的核心环节。检索质量的微小提升,往往能带来生成质量的显著改善。根据我们的经验,优化检索环节带来的收益远高于调整 LLM 参数或 Prompt 模板。本文将深入解析稠密检索的局限与稀疏检索的互补价值,系统讲解混合检索、Multi-Query、MMR 多样性保证和 Metadata Filter 四种进阶检索策略。
本文偏理论且知识点较多,但所有知识都是做好RAG检索的基础,因此虽然内容枯燥,但不建议跳过学习。
一、稠密检索与稀疏检索
在RAG工程中,稠密检索和稀疏检索是目前主流的两种检索技术,它们底层逻辑不同,各自具备独特的优势与局限。
1. 稠密检索
稠密检索(Dense Retrieval) 也称语义检索或Embedding检索。其核心是利用预训练语言模型将文本(查询和文档)映射为高维空间中的稠密向量 。因此,我们从《第2章:文本嵌入 ------ 让计算机理解语义的桥梁》到《第6章:构建第一条 RAG 流水线》一直在使用的方法就是稠密检索。
稠密向量(Dense Vector) 的特点是维度中的每一个值都有意义(例如一个1536维的浮点数组)。它通过计算向量之间的距离(如余弦相似度)来衡量文本间的语义相关性,使得含义相近的文本在向量空间中彼此靠近。
稠密检索的优点我们在前面的章节已经详细阐述过。一方面,其拥有 强大的语义理解能 ,能够跨越字面差异识别语义相似。即使查询和文档使用的措辞完全不同(如"忘记密码怎么办"与"密码找回流程"),只要意思相近就能被召回。另一方面是鲁棒性强,在处理同义表达、自然语言改写和非精确措辞时表现优异,特别适合用户问题表达不规范的自然语言场景。
鲁棒性 (Robustness,又译作健壮性)简单来说,就是系统、算法或模型在异常、干扰或不利条件下,依然能够维持正常功能、保持稳定的能力。
稠密检索在检索精确度上的缺点也是比较明显的,主要体现在 精确匹配能力弱 、低频词表征失效 和 短查询不利。
(1)精确匹配能力弱
在面对版本号、错误码、产品型号、专有缩写等需要字面精确匹配的场景,向量相似度并不可靠,容易检索到主题相似但非目标的内容。比如你给一个具体错误代码,最终可能返回类似["错误处理最佳实践", "常见错误类型", "日志记录指南"]这样的内容,但是没有给出对应错误代码具体的修复步骤。
typescript
// 场景:用户查询具体的错误码
const query = "ErrorCode-500";
// 向量检索可能返回所有关于"错误"的文档
// 但不一定是这个具体错误码的解决方案
const results = await vectorRetriever.invoke(query);
// 返回: ["错误处理最佳实践", "常见错误类型", "日志记录指南"]
// 但没有 "ErrorCode-500 的具体修复步骤"
这是因为 Embedding 模型将"ErrorCode-500"映射为一个语义向量,它更接近"错误"这个概念,而不是这个具体的字符串。向量空间中没有"精确匹配"这个概念。
(2)低频词表征失效
对于行业专属低频术语或内部编码,模型难以学习到精准的向量表征,极易与其他相似词汇混淆。比如技术术语缩写("OIDC"、"mTLS"、"gRPC")等这些词汇在通用语料中出现频率低,Embedding 模型无法学到它们的准确语义表示。
(3)短查询不利
比如"ACL 配置"只有两个词,向量能承载的语义信息有限。短查询的问题在于上下文不足,歧义性高、Embedding 模型难以捕捉意图、容易受到噪声干扰。研究表明,当查询长度小于 5 个词时,向量检索的准确率会显著下降。
2. 稀疏检索
稀疏检索(Sparse Retrieval) 基于关键词字面匹配和词频统计的传统检索方式,它通常不涉及神经网络(目前也出现了一些结合神经网络的稀疏检索变体),直接统计查询词在文档中的出现频率和逆文档频率。其代表算法为TF-IDF和BM25。
以BM25(Best Matching 25)为例,它本质上是一个数学公式,主要依赖三个统计学指标:词频(TF)、逆文档频率(IDF)和文档长度归一化。公式如下(简化版):
Score(D,Q)=i∑IDF(qi)⋅TF(qi,D)+k1⋅(1−b+b⋅avgdl∣D∣)TF(qi,D)⋅(k1+1)
TF(qi, D): 词 qi 在文档 D 中的词频IDF(qi): 词 qi 的逆文档频率|D|: 文档长度avgdl: 平均文档长度k1, b: 调节参数
你不需要记住这个公式,只需要理解它的核心思想,即BM25 关注的是词汇的精确匹配和统计特征,如果一个词在某个文档中(局部特征)出现频率高(词频),但在整个文档集合(全局特征)中出现频率低(逆文档频率),那么这个文档与该词的相关性就高。通俗理解,比如"人工智能"这个词虽然在某篇文章里出现100次,但在整个图书馆的几百万本书里,可能有几十万本书都在讲人工智能。既然大家都在讲,那这个词就不那么稀奇了,不能单靠它来精准锁定这篇文章。
在LangChain社区中也提供了BM25检索的支持。
typescript
import { BM25Retriever } from "@langchain/community/retrievers/bm25";
// 对已切分的文档建立 BM25 索引
const bm25Retriever = BM25Retriever.fromDocuments(chunks, {
k: 10, // 返回 Top-10
});
// 查询
const results = await bm25Retriever.invoke("ErrorCode-500");
// 返回包含 "ErrorCode-500" 这个确切字符串的文档
BM25 vs Dense Retrieval 对比:
| 特性 | BM25(稀疏检索) | Dense Retrieval(稠密检索) |
|---|---|---|
| 匹配方式 | 关键词精确匹配 | 语义相似度匹配 |
| 优势场景 | 专有名词、错误码、API 路径 | 同义词、改写、语义理解 |
| 劣势场景 | 同义词、语义转换 | 精确匹配、罕见词汇 |
| 计算成本 | 低(纯统计) | 中(需要 Embedding) |
| 索引大小 | 小(倒排索引) | 大(向量 + 索引) |
| 可解释性 | 高(可以看到匹配的词) | 低(黑盒向量) |
二、混合检索
1. 为什么需要混合检索?
通过上文比较可见稀疏检索和稠密检索的优势天然互补的。稀疏检索擅长精确匹配(专有名词、错误码),而稠密检索擅长语义理解(同义词、改写)。将它们结合起来,可以覆盖更广泛的查询场景。在实际的RAG工程落地中,企业知识往往同时包含"语义问答"和"精确查找"两类需求。因此,单一检索策略很难应对所有场景,真正的工程主流并不是在两者中二选一,而是采用混合检索。
混合检索的核心思想是同时使用多种检索方法,然后融合结果,取长补短。
典型的混合检索架构:
2. Reciprocal Rank Fusion
将多路检索结果融合的最经典实用方法是使用 Reciprocal Rank Fusion( RRF,倒数排名融合)算法 。这是一种不关心绝对分数,只关心相对排名的算法。它的核心作用是当系统同时运行了多个检索器(例如:BM25 稀疏检索 + 稠密向量检索)时,RRF 能够将这些检索器返回的、分数标准完全不同 的结果,巧妙地合并成一个统一的、高质量的排序列表。
换句话说,RRF算法解决的是混合检索中,分数不可比的痛点。比如BM25的分数是基于词频统计的,分数可能是 15.4、8.2 这种没有绝对上限的浮点数,而稠密检索的分数是基于向量余弦相似度,分数通常在 0 到 1 之间(如 0.85、0.72)。如果你直接把这两个分数相加,BM25 的分数会完全主导最终结果,稠密检索就失去了意义。RRF则使用完全不看原始分数,只看排名的策略。
RRF 的核心公式是极其简单优雅的,对于某篇文档 d,它的 RRF 分数计算公式如下:
RRF(d)=r∈R∑k+r(d)1
- R :代表所有的检索器集合(比如 R={BM25,Dense})。
- r(d) :代表文档 d 在某个特定检索器结果列表中的排名位置(从 1 开始数)。
- k :一个平滑常数(通常默认设为 60)。
这个公式的含义是排名越靠前,贡献的分数越高;但同时出现在多路检索中的文档,分数会累加。
举个例子,假设用户搜索"大语言模型",系统同时触发了 BM25 和 稠密检索。
- 文档 A :在 BM25 中排第 1 ,在 稠密检索中排第 10。
- 文档 B :在 BM25 中排第 5 ,在 稠密检索中排第 1。
我们来计算它们的 RRF 分数(设 k=60):
- 文档 A 的 RRF 分数 : 1/(60+1)+1/(60+10)=0.01639+0.00142≈0.0178
- 文档 B 的 RRF 分数 : 1/(60+5)+1/(60+1)=0.00153+0.01639≈0.0179
结果分析:虽然文档 A 在 BM25 中是绝对的第一名,但文档 B 在稠密检索中是第一名。综合计算下来,文档 B 以极其微弱的优势(0.0179 > 0.0178)胜出。这说明 RRF 很好地平衡了两个检索器的意见。
你可能会有疑惑,为什么公式里要加一个常数 k?这是因为如果不加 k,公式变成 1/r。那么排第 1 名的分数是 1.0,排第 2 名是 0.5,排第 3 名是 0.33。你会发现头部排名的分数差距极其悬殊 ,第 1 名会形成绝对的统治力,后面的排名几乎没有话语权。加上常数 k=60 后,第 1 名是 1/61,第 2 名是 1/62,第 3 名是 1/63。这极大地压缩了头部排名的分数差距,使得排在第 5 名、第 10 名的文档也有机会通过在其他检索器中的优异表现而被召回。
typescript
interface SearchResult {
doc: Document;
score: number;
rank: number;
}
function reciprocalRankFusion(
denseResults: SearchResult[],
sparseResults: SearchResult[],
k: number = 60
): Array<{ doc: Document; score: number }> {
const rrfScores = new Map<string, { doc: Document; score: number }>();
// 处理稠密检索结果
for (const [rank, item] of denseResults.entries()) {
const id = getDocId(item.doc);
const existing = rrfScores.get(id);
if (existing) {
// 如果已存在,累加分数
existing.score += 1 / (k + rank);
} else {
// 否则新建
rrfScores.set(id, {
doc: item.doc,
score: 1 / (k + rank),
});
}
}
// 处理稀疏检索结果
for (const [rank, item] of sparseResults.entries()) {
const id = getDocId(item.doc);
const existing = rrfScores.get(id);
if (existing) {
existing.score += 1 / (k + rank);
} else {
rrfScores.set(id, {
doc: item.doc,
score: 1 / (k + rank),
});
}
}
// 按融合分数排序
return Array.from(rrfScores.values())
.sort((a, b) => b.score - a.score);
}
/**
* 获取文档的唯一 ID
*/
function getDocId(doc: Document): string {
// 优先使用 metadata 中的 ID
if (doc.metadata.id) {
return doc.metadata.id;
}
// 否则使用内容哈希
const hash = createHash('md5')
.update(doc.pageContent.slice(0, 200))
.digest('hex');
return hash;
}
RRF 在 RAG 工程中的优势
- 参数简单 :只有一个参数
k,通常取 60 即可,不需要去研究 BM25 和向量分数的分布,开箱即用。 - 抗噪性强:某个检索器如果返回了异常高或异常低的分数,只要它的排名没有变,就不会影响最终的融合结果。
- 计算极快:只需要遍历一次结果列表做简单的加法,时间复杂度极低。
当然,有时候我们还是需要对k参数进行调优,一般来讲 k=60可以适合大多数场景。k 值越小,排名的影响力衰减越快,前几名的优势更大;反之k 越大排名的影响力衰减越慢,更多文档有机会进入最终结果。
typescript
// 实验不同的 k 值
for (const k of [20, 60, 100]) {
const fused = reciprocalRankFusion(denseResults, sparseResults, k);
console.log(`k=${k}: Top-1 分数 = ${fused[0].score.toFixed(4)}`);
}
3. 完整的混合检索器
将 RRF 封装为一个可复用的混合检索器:
typescript
import { BM25Retriever } from "@langchain/community/retrievers/bm25";
import { VectorStoreRetriever } from "@langchain/core/vectorstores";
import { Document } from "@langchain/core/documents";
interface HybridRetrieverOptions {
denseRetriever: VectorStoreRetriever;
sparseRetriever: BM25Retriever;
topK?: number;
rrfK?: number;
weights?: {
dense?: number; // 稠密检索权重
sparse?: number; // 稀疏检索权重
};
}
class HybridRetriever {
private denseRetriever: VectorStoreRetriever;
private sparseRetriever: BM25Retriever;
private topK: number;
private rrfK: number;
private weights: { dense: number; sparse: number };
constructor(options: HybridRetrieverOptions) {
this.denseRetriever = options.denseRetriever;
this.sparseRetriever = options.sparseRetriever;
this.topK = options.topK || 5;
this.rrfK = options.rrfK || 60;
this.weights = {
dense: options.weights?.dense || 1.0,
sparse: options.weights?.sparse || 1.0,
};
}
async invoke(query: string): Promise<Document[]> {
// 并行执行两路检索
const [denseResults, sparseResults] = await Promise.all([
this.denseRetriever.invoke(query),
this.sparseRetriever.invoke(query),
]);
console.log(`稠密检索返回 ${denseResults.length} 个结果`);
console.log(`稀疏检索返回 ${sparseResults.length} 个结果`);
// 转换为 RRF 格式
const denseFormatted = denseResults.map((doc, rank) => ({
doc,
score: this.weights.dense / (this.rrfK + rank),
rank,
}));
const sparseFormatted = sparseResults.map((doc, rank) => ({
doc,
score: this.weights.sparse / (this.rrfK + rank),
rank,
}));
// RRF 融合
const fused = reciprocalRankFusion(denseFormatted, sparseFormatted, this.rrfK);
// 返回 Top-K
const finalResults = fused.slice(0, this.topK).map(r => r.doc);
console.log(`融合后返回 ${finalResults.length} 个结果`);
return finalResults;
}
}
// 使用示例
async function createHybridRetriever(
vectorStore: Chroma,
chunks: Document[]
): Promise<HybridRetriever> {
// 创建稠密检索器
const denseRetriever = vectorStore.asRetriever({ k: 10 });
// 创建稀疏检索器
const sparseRetriever = BM25Retriever.fromDocuments(chunks, { k: 10 });
// 创建混合检索器
return new HybridRetriever({
denseRetriever,
sparseRetriever,
topK: 5,
rrfK: 60,
weights: { dense: 1.0, sparse: 1.0 },
});
}
关键设计要点:
- 并行执行:两路检索可以并行执行,减少总延迟
- 可调权重 :通过
weights参数可以调整两路检索的重要性 - 灵活配置 :
topK、rrfK都可以根据业务需求调整
4. 加权融合的替代方案
除了 RRF,在 RAG工程中,也可以使用加权融合(Linear Weighted Fusion),也称为线性加权或分数归一化融合,是最直观的一种混合检索结果合并策略。的核心思想是将不同检索器(如 BM25 稀疏检索和 Dense 稠密检索)返回的原始分数,通过一定的数学方法映射到同一个数值区间(通常是 0 到 1 之间),然后按照预设的权重比例进行线性相加,得出最终的融合分数。
加权融合的计算通常分为两步:分数归一化(Normalization) 和 线性加权求和(Linear Combination)。
typescript
function weightedFusion(
denseResults: Array<{ doc: Document; score: number }>,
sparseResults: Array<{ doc: Document; score: number }>,
denseWeight: number = 0.7,
sparseWeight: number = 0.3
): Array<{ doc: Document; score: number }> {
const combined = new Map<string, { doc: Document; score: number }>();
// 归一化分数到 0-1 范围
const normalize = (results: typeof denseResults) => {
const maxScore = Math.max(...results.map(r => r.score));
return results.map(r => ({
doc: r.doc,
score: r.score / maxScore,
}));
};
const normalizedDense = normalize(denseResults);
const normalizedSparse = normalize(sparseResults);
// 合并分数
for (const item of normalizedDense) {
const id = getDocId(item.doc);
combined.set(id, {
doc: item.doc,
score: item.score * denseWeight,
});
}
for (const item of normalizedSparse) {
const id = getDocId(item.doc);
const existing = combined.get(id);
if (existing) {
existing.score += item.score * sparseWeight;
} else {
combined.set(id, {
doc: item.doc,
score: item.score * sparseWeight,
});
}
}
return Array.from(combined.values())
.sort((a, b) => b.score - a.score);
}
加权融合的优势是直观且可以精细控制每路的贡献,但需要先归一化分数,实现复杂度更高,因此并不常用。对于大多数场景,RRF 已经足够好。
三、Multi-Query Retriever
1. 为什么需要 Multi-Query?
同一问题从不同角度提问,可能命中不同的相关文档。但用户通常只会用一种方式表达问题,这可能导致遗漏相关信息。比如用户问:"怎么部署?"这样一个如此简短的问题,可能的意图包括:
- 部署的步骤是什么?
- 如何上线新版本?
- 发布流程是怎样的?
- 部署需要哪些准备工作?
如果只用原始查询检索,可能只能召回部分相关文档。但如果同时用这四个变体检索,召回率会显著提升。
Multi-Query Retriever(多查询检索) 就是用来处理这种问题的。
2. Multi-Query 的工作原理
Multi-Query的核心思路是"一个问题,多种问法,多路检索"。它是通过大模型(LLM)从不同角度改写用户的原始问题,生成多条(一般是3-5 个)差异化个改写版本,然后再分别去进行检索后合并去重。
你可能听说过多路召回,它们两个有本质的区别。总的来讲,** Multi-Query 解决的是"表述差异"问题,而多路召回 解决的是"匹配维度"问题。** 实际的 RAG 系统中,这两者并不是非此即彼的关系,而是经常组合使用的实践。后续章节将详细介绍多路召回。
3. 使用LangChain 实现 Multi-Query
LangChain 提供了内置的 MultiQueryRetriever。
typescript
import { MultiQueryRetriever } from "langchain/retrievers/multi_query";
import { ChatOpenAI } from "@langchain/openai";
const multiQueryRetriever = MultiQueryRetriever.fromLLM({
llm: new ChatOpenAI({
modelName: "gpt-4o",
temperature: 0.7, // 稍高的温度增加多样性
}),
retriever: vectorStore.asRetriever({ k: 5 }),
queryCount: 3, // 生成 3 个改写版本
});
// 使用
const docs = await multiQueryRetriever.invoke("怎么部署?");
console.log(`返回 ${docs.length} 个文档`);
以上代码,内部的执行流程大致是:
- LLM 接收原始查询,生成 3 个改写版本
- 四个版本(原始 + 3 个改写)分别检索
- 合并所有检索结果,去重
- 返回最终的 Top-K
4. 自定义 Multi-Query 实现
如果你想更精细地控制改写过程,也可以自己实现 Multi-Query。自定义实现有很多优势,你可以根据实际是否有这些需求来决定是否使用自定义Multi-Query:
- 可以控制改写的质量(通过调整 Prompt)
- 可以添加缓存,避免重复生成改写
- 可以记录日志,分析改写效果
- 可以根据查询类型动态调整改写数量
通过下面的自定义实现Multi-Query, 你可以大致掌握LangChain底层实现Multi-Query的基本原理。
typescript
import { ChatOpenAI } from "@langchain/openai";
import { HumanMessage, SystemMessage } from "@langchain/core/messages";
class CustomMultiQueryRetriever {
private llm: ChatOpenAI;
private baseRetriever: VectorStoreRetriever;
private queryCount: number;
constructor(options: {
llm: ChatOpenAI;
retriever: VectorStoreRetriever;
queryCount?: number;
}) {
this.llm = options.llm;
this.baseRetriever = options.retriever;
this.queryCount = options.queryCount || 3;
}
async invoke(query: string): Promise<Document[]> {
// 第一步:生成改写版本
const rewrittenQueries = await this.generateRewrites(query);
console.log(`原始查询: "${query}"`);
console.log(`改写版本:`, rewrittenQueries);
// 第二步:并行检索所有版本
const allQueries = [query, ...rewrittenQueries];
const retrievalPromises = allQueries.map(q =>
this.baseRetriever.invoke(q)
);
const allResults = await Promise.all(retrievalPromises);
// 第三步:合并去重
const uniqueDocs = this.mergeAndDeduplicate(allResults);
console.log(`合并后返回 ${uniqueDocs.length} 个唯一文档`);
return uniqueDocs;
}
/**
* 使用 LLM 生成查询改写版本
*/
private async generateRewrites(originalQuery: string): Promise<string[]> {
const systemPrompt = `你是一个查询改写专家。请为给定的用户查询生成 ${this.queryCount} 个不同角度的改写版本。
要求:
1. 保持原意不变
2. 使用不同的表达方式
3. 每个改写版本一行
4. 不要添加编号或额外说明`;
const userPrompt = `原始查询: ${originalQuery}`;
const messages = [
new SystemMessage(systemPrompt),
new HumanMessage(userPrompt),
];
const response = await this.llm.invoke(messages);
const content = response.content as string;
// 解析输出,每行一个改写
const rewrites = content
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0)
.slice(0, this.queryCount);
return rewrites;
}
/**
* 合并多路检索结果并去重
*/
private mergeAndDeduplicate(results: Document[][]): Document[] {
const seen = new Set<string>();
const uniqueDocs: Document[] = [];
for (const docs of results) {
for (const doc of docs) {
const id = getDocId(doc);
if (!seen.has(id)) {
seen.add(id);
uniqueDocs.push(doc);
}
}
}
return uniqueDocs;
}
}
// 使用示例
const customRetriever = new CustomMultiQueryRetriever({
llm: new ChatOpenAI({ modelName: "gpt-4o", temperature: 0.7 }),
retriever: vectorStore.asRetriever({ k: 5 }),
queryCount: 3,
});
const docs = await customRetriever.invoke("怎么部署?");
5. Multi-Query的弊端
Multi-Query本质上是一种"用计算资源换取召回广度"的策略。虽然能显著提升 RAG 系统的召回率,但在实际工程落地中,它并非完美的解决方案,也存在一定的弊端,主要体现在 性能与成本的双重增加、检索质量与噪音风险等方面。
- 成本增加:生成查询变体需要额外调用大语言模型,这意味着单次问答的 LLM 调用次数和向量检索次数都会成倍增加,直接推高了 API 调用成本和计算资源消耗。当然,通常情况下,这个额外的输出可能仅增加较为有限的Token开销,实际成本在大部分情况下可以忽略不计。
- 性能影响(延迟显著增加):延由于需要生成多个查询变体,并针对每个变体分别执行检索,整体耗时约为单次检索的k 倍( k为生成的查询数量)。虽然可以通过并行检索来缓解,但依然会增加系统的整体响应时间。
- 检索质量与噪音风险:这个主要体现为引入无关信息与噪音、查询同质化和实体与约束被破坏三个方面。一方面LLM 生成的某些查询变体可能会偏离用户原意或过于宽泛,从而召回大量不相关的文档。这些噪音会给后续的答案生成带来干扰,增加大模型提取准确信息的难度。另一方面模型有时生成的变体仅仅是简单的同义词替换或句式调整,缺乏实质性的视角差异。这有时不仅无法提升召回效果,反而白白增加了检索成本。最具破坏性的是在改写过程中,LLM 可能会错误地修改或遗漏原始查询中的关键实体(如特定的编号、型号、合同号等),导致检索结果完全偏离目标。
因此,一般建议Multi-Query在短查询(< 5 个词)、模糊查询(意图不明确)、 对召回率要求高的场景等场景下使用,而对于延迟敏感、查询已经很明确的场景要少用。
四、MMR 保证结果多样性
在 RAG系统中,MMR(Maximum Marginal Relevance,最大边际相关性) 是一种用于优化检索结果的重排序算法。它的核心目的是解决传统向量检索容易返回高度相似、内容重复文档的痛点,确保最终呈现给大模型或用户的上下文既高度相关 又丰富多样。
1. 为什么需要保证多样性?(核心痛点)
传统的向量检索(如 Top-K 检索)仅关注文档与查询的语义相似度。这会导致一个明显的问题:如果知识库中有多篇文档从不同角度讨论了同一个核心概念,检索结果往往会被这几篇高度雷同的文档"霸榜"。这进而导致:
- 信息冗余:大模型接收到大量重复信息,不仅浪费 Token 额度,还可能产生"信息茧房"效应,导致回答片面。
- 缺乏全局视角:用户可能希望了解一个问题的多个方面(例如"人工智能"的医疗应用、教育应用和伦理问题),但单一维度的检索无法满足这种多角度需求。
2. MMR 的核心原理
MMR 算法在每次选择下一个检索结果时,会同时权衡相关性(Relevance)和 多样性(Diversity) 两个相互竞争的目标。其中,相关性用于衡量候选文档与用户查询的匹配程度,多样性用于衡量候选文档与已经被选入结果集的文档之间的差异程度。
MMR 的目标函数:
MMR=argDi∈R∖Dmaxλ⋅Sim(Di,Q)−(1−λ)⋅Dj∈DmaxSim(Di,Dj)
Sim(Di, Q): 文档 Di 与查询 Q 的相似度Sim(Di, Dj): 文档 Di 与已选文档 Dj 的相似度λ: 平衡参数(0-1)。λ=1为纯相关性,退化为标准检索;λ=0为纯多样性,完全不考虑相关性;λ=0.5则平衡相关性和多样性(常用值)。R: 候选文档集合D: 已选文档集合
上面的公式看着比较复杂,其实也可以通俗地理解为:
MMR 得分 = λ × (与查询的相似度) - (1 - λ) × (与已选文档的最大相似度)
其中, λ 参数 的取值在 0 到 1 之间。λ 越大,越偏向相关性(λ=1 时退化为纯相似度检索);λ 越小,越偏向多样性,会刻意挑选与已选内容差异大的文档。同时还提供了一种 惩罚机制,如果一个文档虽然与查询很相关,但它和已经入选的文档太像了,公式后半部分的"惩罚项"就会变大,从而拉低它的最终得分,让其他不那么相似但同样相关的文档有机会入选。
总结来说,MMR 就像是一个"懂信息又懂用户心理的策展人",它能在保证答案切题的前提下,为你提供尽可能丰富、不重复的信息增量。
3. 直观案例演示
假设用户查询"人工智能",系统需要选出 3 篇文档。
-
传统 Top-3 检索结果:
- 《AI 在医疗诊断中的应用》(相似度 0.95)
- 《人工智能辅助医疗影像分析》(相似度 0.93)
- 《AI 医疗大模型的最新突破》(相似度 0.91) (结果:三篇都在讲医疗,信息高度重复)
-
经过 MMR 重排序后的结果:
- 《AI 在医疗诊断中的应用》(相似度 0.95,首个入选)
- 《AI 在教育领域的个性化辅导》(相似度 0.88,因为与医疗文档差异大,MMR 得分逆袭)
- 《人工智能的伦理与法律监管》(相似度 0.85,提供了全新的视角) (结果:兼顾了核心相关性与多领域视角的多样性)
4. 在LangChain 中实现MMR
在实际工程中,MMR 通常作为检索后的重排序(Rerank)或后处理阶段 执行。以 LangChain 框架为例,MMR 检索通常分为两步:扩大候选池(fetch_k)和MMR 筛选(k)。即首先基于语义相似度,拉取一个较大的初始文档集(例如 fetch_k=10),然后在这 10 篇文档中,运用 MMR 算法进行去重和多样性打分,最终精选出指定数量(例如 k=5)的最优文档返回给大模型。
typescript
import { vectorStore } from "./setup";
const retriever = vectorStore.asRetriever({
k: 5,
searchType: "mmr", // 启用 MMR
searchKwargs: {
fetchK: 20, // 先拿 20 个候选,再从中选 5 个
lambda: 0.5, // 0=纯多样性, 1=纯相关性
},
});
const docs = await retriever.invoke("如何配置数据库连接?");
fetchK用于先从向量数据库中检索出多少个候选文档。MMR 需要有一个候选池来挑选多样化的结果。通常设为 k 的 3-5 倍。
5. 自定义 MMR 实现
如果你想更深入理解 MMR 的工作原理,可以自己实现。
typescript
function maximalMarginalRelevance(
queryEmbedding: number[],
candidateDocs: Array<{ doc: Document; embedding: number[] }>,
k: number,
lambda: number = 0.5,
fetchK: number = 20
): Document[] {
// 第一步:计算所有候选文档与查询的相似度
const candidates = candidateDocs.map(item => ({
doc: item.doc,
embedding: item.embedding,
querySimilarity: cosineSimilarity(queryEmbedding, item.embedding),
}));
// 按查询相似度度排序,取前 fetchK 个
candidates.sort((a, b) => b.querySimilarity - a.querySimilarity);
const pool = candidates.slice(0, fetchK);
// 第二步:贪心选择 k 个文档
const selected: typeof candidates = [];
const remaining = [...pool];
while (selected.length < k && remaining.length > 0) {
let bestIndex = 0;
let bestScore = -Infinity;
for (let i = 0; i < remaining.length; i++) {
const candidate = remaining[i];
// 计算与已选文档的最大相似度
let maxSimToSelected = 0;
for (const sel of selected) {
const sim = cosineSimilarity(candidate.embedding, sel.embedding);
maxSimToSelected = Math.max(maxSimToSelected, sim);
}
// MMR 分数
const mmrScore = lambda * candidate.querySimilarity -
(1 - lambda) * maxSimToSelected;
if (mmrScore > bestScore) {
bestScore = mmrScore;
bestIndex = i;
}
}
// 选择得分最高的文档
selected.push(remaining[bestIndex]);
remaining.splice(bestIndex, 1);
}
return selected.map(s => s.doc);
}
MMR 的优势是减少信息冗余,提高信息密度、 覆盖更多不同的文档来源提升用户体验(看到更多样化的内容);劣势计算成本高(需要计算候选文档之间的两两相似度)、可能降低相关性(为了多样性,可能牺牲一些相关性)、参数调优复杂(lambda 和 fetchK 需要根据场景调整)。
5. MMR 的效果评估
在 RAG系统中,评估 MMR的效果不能仅看传统的检索准确率,而必须引入 多样性和信息覆盖率 这两个核心维度。评估 MMR 的效果通常需要从离线实验和在线业务两个层面来进行。
在离线评估时,针对多样性这个维度,可以通过计算返回的 Top-K 文档两两之间的平均相似度,MMR 的目标是显著降低这个值;针对信息覆盖率这个维度,可以统计返回的 Top-K 文档中,涵盖了多少个不同的子主题或聚类。例如,查询"苹果",传统检索可能 10 篇全是水果,而 MMR 检索可能包含 3 篇水果、3 篇科技、2 篇娱乐、2 篇历史,覆盖率大幅提升。同时,在进行λ 参数调优的过程中,必须监控这些相关性传统指标,确保 MMR 没有为了多样性而牺牲太多核心相关性。
离线指标好不代表用户体验好,MMR 的最终目的是服务于大模型生成或用户阅读,因此在线评估至关重要。在引入 MMR 后,要评估大模型生成的答案是否涵盖了问题的多个维度,检查大模型是否因为上下文中包含了更多维度的信息,而减少了"胡编乱造"的概率。另外还要关注用户行为与反馈指标(比如点赞/踩比例/追问率),如果 MMR 效果好,用户一次性获取了全面信息,追问率通常会下降;反之,如果 MMR 召回了太多边缘噪音,用户可能会频繁追问澄清。
当然,在做所有评估之前,必须设置严格的 Baseline(如纯向量检索 Top-K、RRF 融合检索)。只有当 MMR 的多样性显著高于 Baseline,且相关性下降在可接受范围内(通常 < 5%)时,才能认为 MMR 评估达标。
以下是一个计算多样性平均值的示例,理想情况下,启用 MMR 后,多样性应该提升 20%-50%。
typescript
function evaluateDiversity(docs: Document[]): number {
// 计算文档之间的平均相似度
let totalSimilarity = 0;
let count = 0;
for (let i = 0; i < docs.length; i++) {
for (let j = i + 1; j < docs.length; j++) {
const sim = cosineSimilarity(
docs[i].embedding,
docs[j].embedding
);
totalSimilarity += sim;
count++;
}
}
const avgSimilarity = count > 0 ? totalSimilarity / count : 0;
// 多样性 = 1 - 平均相似度
return 1 - avgSimilarity;
}
// 对比启用 MMR 前后的多样性
const docsWithoutMMR = await standardRetriever.invoke(query);
const docsMMR = await mmrRetriever.invoke(query);
console.log(`无 MMR 多样性: ${evaluateDiversity(docsWithoutMMR).toFixed(3)}`);
console.log(`有 MMR 多样性: ${evaluateDiversity(docsMMR).toFixed(3)}`);
五、Metadata Filter
1. 为什么需要元数据过滤?
在实际应用中,你很少需要对整个数据库进行搜索。通常会有一些前置条件,比如:
- 只搜索某个类别的文档(
category = "technical") - 只搜索最近更新的文档(
date > "2026-01-01") - 只搜索特定作者的文档(
author = "张三")
如果先做向量搜索再过滤,可能召回的结果都被过滤掉了,导致空结果。正确的做法是在搜索时就应用过滤条件。
Metadata Filter(元数据过滤) 就是这样一种通过在语义匹配的基础上,利用结构化标签来精准限制搜索范围、排除噪声数据的核心技术。
在前面关于文本嵌入、文本切分和向量存储的相关章节中,我们介绍过将文档的元素据作为结构化标签附加在文档块(Chunk)上(通常包含分类、日期、权限等属性),Metadata Filter就是这些结构化标签的用处之一。
2. Chroma元数据过滤的使用示例
typescript
// 仅搜索特定日期之后的文档
const results = await collection.query({
queryEmbeddings: [queryEmbedding],
nResults: 5,
where: {
date: { $gte: "2026-01-01" },
category: "technical",
},
});
Chroma支持的过滤操作符十分丰富,比如: $eq(等于)、$ne(不等于)、 $gt(大于)、 $gte(大于等于)、 $lt(小于)、 $lte(小于等于)、 $in(包含于列表)、 $nin(不包含于列表)。同时还支持操作符的组合使用:
typescript
// AND 条件(默认)
where: {
category: "technical",
date: { $gte: "2026-01-01" },
}
// OR 条件(需要使用 $or)
where: {
$or: [
{ category: "technical" },
{ category: "business" },
],
}
一般来讲,数据库都支持过滤操作符,比如Pinecone 的过滤语法与 Chroma 类似,但功能更强大,支持更复杂的嵌套条件。
3. 元数据过滤的最佳实践
(1)在加载阶段就规划好元数据
typescript
// 不好的做法:元数据缺失
const doc = new Document({
pageContent: "...",
metadata: {}, // 空的或极少属性
});
// 好的做法:丰富的元数据
const doc = new Document({
pageContent: "...",
metadata: {
source: "docs/deploy-guide.md",
fileType: "markdown",
category: "technical",
author: "张三",
createdAt: "2026-05-20",
updatedAt: "2026-05-27",
version: "v2.1",
tags: ["deployment", "devops"],
},
});
(2)使用标准化的元数据字段
定义一套标准的元数据 schema,确保所有文档都有一致的字段:
typescript
interface DocumentMetadata {
source: string; // 来源
fileType: string; // 文件类型
category: string; // 分类
author?: string; // 作者(可选)
createdAt: string; // 创建时间
updatedAt: string; // 更新时间
version?: string; // 版本号(可选)
tags?: string[]; // 标签(可选)
language: string; // 语言
}
(3)利用元数据进行智能路由
根据查询类型自动选择合适的过滤条件:
typescript
async function smartSearch(query: string): Promise<Document[]> {
// 检测查询类型
const queryType = detectQueryType(query);
let filter: any = {};
switch (queryType) {
case "api":
// API 相关问题,只搜索 API 文档
filter.category = "api";
break;
case "deploy":
// 部署相关问题,只搜索最近的部署文档
filter.category = "deployment";
filter.updatedAt = { $gte: "2026-01-01" };
break;
case "troubleshooting":
// 故障排查,搜索所有技术文档
filter.category = { $in: ["technical", "troubleshooting"] };
break;
default:
// 默认不过滤
break;
}
return await collection.query({
queryEmbeddings: [await getEmbedding(query)],
nResults: 5,
where: filter,
});
}
六、检索策略选择指南
面对这么多检索策略,可以参照以下做技术选型决策。
| 策略 | 解决什么问题 | 额外成本 | 建议使用时机 |
|---|---|---|---|
| 混合检索 | 语义匹配失败 + 精确匹配失败 | 维护双索引,检索延迟略增 | 专有名词多的领域(技术文档、API 文档) |
| Multi-Query | 查询表述不充分,召回率低 | LLM 调用次数 +1,延迟增加 | 短查询、模糊查询、用户表达能力弱的场景 |
| MMR | 检索结果同质化,信息冗余 | 计算复杂度增加 O(k²) | Top-K 来自同一文档时,探索性查询 |
| Metadata Filter | 检索范围过大,噪声多 | 无显著增加 | 文档有明显分类特征时,多租户场景 |
建议不要一次引入所有策略。按照以下顺序做渐进式优化策略:
- ** baseline**:先用标准向量检索建立基线
- 第一步:引入混合检索(性价比最高的改进)
- 第二步:如果检索结果仍存在同质化问题,加入 MMR
- 第三步:如果短查询多,加入 Multi-Query
- 第四步:如果文档有明显分类,加入 Metadata Filter
每一步优化都要做效果评估,可以参考《第12章:RAG 系统评估与指标体系》的评估指标量化效果,确保每次改进都有实际收益。
这里也提供一些典型的场景可能需要用到的检索策略:
(1)技术文档问答
- 特点:大量专有名词、API 路径、错误码
- 推荐:混合检索(BM25 + Dense)+ Metadata Filter(按版本过滤)
(2)客服系统
- 特点:用户表述多样、短查询多
- 推荐:Multi-Query + MMR(提高召回率和多样性)
(3)法律/医疗文献检索
- 特点:需要高精度、可溯源
- 推荐:混合检索 + Metadata Filter(按日期/类别过滤)+ 严格的引用标注
(4)企业内部知识库
- 特点:多部门、多类别、更新频繁
- 推荐:Metadata Filter(按部门/类别过滤)+ 混合检索
FAQ
Q1:混合检索的两路权重应该怎么调?
RRF 的默认权重是 1:1,适合大多数场景。如果发现精确关键词匹配的结果经常比语义结果更相关(技术文档类场景),可以给 BM25 更高的权重:
typescript
new HybridRetriever({
// ...
weights: { dense: 0.7, sparse: 1.3 }, // BM25 权重更高
});
也可以通过实验确定最优权重:
typescript
for (const denseWeight of [0.5, 0.7, 1.0]) {
for (const sparseWeight of [0.5, 0.7, 1.0]) {
const retriever = new HybridRetriever({
// ...
weights: { dense: denseWeight, sparse: sparseWeight },
});
const metrics = await evaluateRetriever(retriever, testCases);
console.log(`dense=${denseWeight}, sparse=${sparseWeight}: MRR=${metrics.mrr}`);
}
}
Q2:Multi-Query 会增加多少成本?
每次查询额外调用 1 次 LLM(生成改写版本),输出几十个 Token,成本可忽略。以 gpt-4o 为例:
bash
改写生成: 50 input tokens × $5/1M + 100 output tokens × $15/1M = $0.00175
但延迟会增加 1-2 秒(生成改写版本 + 多路检索)。如果延迟是关键指标,可以考虑缓存常用的改写结果、使用更快的 LLM(如 gpt-3.5-turbo)、只在检测到短查询时才启用 Multi-Query。
Q3:MMR 的 lambda 参数如何选择?
lambda 的选择取决于你的业务需求。
- 0.3-0.5:偏向多样性,适合探索性查询(如"给我一些关于 X 的资料")
- 0.5-0.7:平衡模式,适合大多数场景
- 0.7-0.9:偏向相关性,适合精确查询(如"X 的具体步骤是什么")
建议从 0.5 开始,然后根据用户反馈调整。如果用户抱怨"结果太分散",提高 lambda;如果抱怨"结果太重复",降低 lambda。
Q4:元数据过滤会影响检索性能吗?
轻微的。现代向量数据库(Chroma、Pinecone、Qdrant)都对元数据过滤做了优化,过滤操作通常在毫秒级完成。
但如果过滤条件过于复杂(如多层嵌套的 OR 条件),可能会影响性能。建议保持过滤条件简洁、避免在高基数字段上过滤(如用户 ID)、定期清理无效的元数据值。
Q5:可以同时使用多种检索策略吗?
可以,而且推荐这样做。例如:
typescript
// 混合检索 + MMR + Metadata Filter
const hybridRetriever = new HybridRetriever({
denseRetriever: vectorStore.asRetriever({
k: 20,
searchType: "mmr",
searchKwargs: { fetchK: 50, lambda: 0.5 },
filter: { category: "technical" }, // Metadata Filter
}),
sparseRetriever: bm25Retriever,
topK: 5,
});
这种组合可以同时享受多种策略的优势。但要注意每增加一层策略,复杂度都会上升。因此需要充分测试,确保各策略协同工作, 用评估指标验证每次改进的效果。
练习
练习 1:实现并对比混合检索
在你《第7章:基础篇实战:文档问答机器人》的项目中实现混合检索器,准备 5 个包含专有名词的查询(如 API 端点名称、错误码),分别用纯向量检索和混合检索测试。
验证标准:
- 混合检索在包含专有名词的查询上 MRR 至少提升 10%
- 能够展示两路检索的结果差异
- 分析哪些查询从混合检索中受益最大
提示:
typescript
// 测试用例示例
const testCases = [
{
query: "ErrorCode-500 如何解决?",
relevantDocIds: ["doc_123"], // 包含该错误码的文档
},
{
query: "/api/v2/users 接口怎么用?",
relevantDocIds: ["doc_456"], // API 文档
},
// ...
];
练习 2:使用 Multi-Query 提升短查询召回
用 5 个短查询(≤ 5 个词)对比 Multi-Query Retriever 和普通 Retriever 的召回率差异。
验证标准:
- Multi-Query 的 Recall@5 比普通检索高至少 20%
- 能够展示生成的改写版本
- 分析哪些改写版本最有效
提示:
typescript
const shortQueries = [
"部署流程",
"API 认证",
"数据库配置",
"错误处理",
"权限管理",
];
练习 3:实验 MMR 的不同参数
对同一个查询,尝试不同的 lambda 值(0.3、0.5、0.7、0.9),对比检索结果的多样性和相关性。
验证标准:
- 能够量化多样性指标(文档间的平均相似度)
- 人工评估每种参数下的结果质量
- 给出推荐的 lambda 值及理由
提示:
typescript
for (const lambda of [0.3, 0.5, 0.7, 0.9]) {
const retriever = vectorStore.asRetriever({
k: 5,
searchType: "mmr",
searchKwargs: { fetchK: 20, lambda },
});
const docs = await retriever.invoke(query);
const diversity = evaluateDiversity(docs);
console.log(`lambda=${lambda}: diversity=${diversity.toFixed(3)}`);
}
练习 4:实现智能元数据过滤
根据查询类型自动选择合适的过滤条件。例如:
- 包含"API"的查询 → 只搜索 API 文档
- 包含"部署"的查询 → 只搜索最近的部署文档
- 其他查询 → 不过滤
验证标准:
- 能够准确识别查询类型
- 过滤后的检索结果更相关
- 不会因为过度过滤而丢失相关信息
📚 延伸阅读
- Improving Retrieval with Multi-Query --- LangChain Multi-Query Retriever 官方文档,包含更多使用示例
- BM25 论文 --- Robertson & Zaragoza, "The Probabilistic Relevance Framework: BM25 and Beyond",想了解 BM25 数学原理的读者可以阅读
- Reciprocal Rank Fusion 优于 Condorcet --- Cormack et al., RRF 原始论文,详细分析了 RRF 的理论基础
- MMR 原始论文 --- Carbonell & Shahabi, "The Use of MMR, Diversity-Based Reranking for Reordering Documents and Producing Summaries"
- Pinecone Hybrid Search --- Pinecone 的混合搜索官方指南,介绍了如何在云端实现混合检索