版权与内容来源声明
本文为原创整理。文中涉及官方文档、开源仓库、论文与公开报道的内容,均在附表 A 中标注来源;引用官方原文保持原样,不作改写。文中命令、版本号与界面截图以本文成文时的实测/核验结果为准,标注「待验证」的部分请以你本地环境实际输出为判断依据。本文不推荐任何不合规的软件获取方式,也不对任何收益结果作承诺。转载请注明出处。
一、先把候选池喂进去:返回的 5 条全部落在同一个簇里
先看结果。同一份候选池、同一个 query,两条检索路线各取 5 条,原样输出如下(完整输出见第 2 章):
策略 结果集 内部平均相似度 异主题条数 纯相似度 ['A1', 'A4', 'A3', 'A6', 'A5'] 0.978 0 MMR lam=0.5 ['A1', 'B1', 'A6', 'A4', 'A3'] 0.530 1 MMR lam=0.9 ['A1', 'A4', 'A3', 'A6', 'A5'] 0.978 0纯相似度取回来的 5 条,5 条都来自同一个主题簇;把 MMR 的 λ 抬到 0.9,输出的还是这 5 条,一位都没换。
这就是「召回一堆重复内容」在数字上的样子:不是某一条不相关,而是整个结果集里没有新信息。
标题里的「5 条有 4 条是同一篇」是这个问题最常见的形态——语料里同一主题有两三份近重复文档,检索把它们全捞上来。本文的候选池把近重复密度调到极端(同一主题 8 篇),于是推到了 5 条全中。这不是另一种病,只是把同一种病放大到看得清。
这里先把两个词切开:「相似度高的结果」讲的是单条,「不冗余的结果集」讲的是集合。做 RAG 的时候我们大多在优化前者(换 embedding、调分块、调 rerank 的 k),而冗余是后者的属性,盯单条的人再久也看不见它。
1.1 候选池的形状决定了这件事能不能复现
14 篇候选:一个主题 8 篇近重复(记作 A 组,A1~A8),另外三个主题各 2 篇(B1/B2、C1/C2、D1/D2),取 top-5。
如果池子里每个主题只放 1 篇,纯相似度取 top-5 天然就是 5 个不同主题,λ 怎么调都测不出差别——**要复现冗余,候选池里必须先有近重复。**这也解释了为什么本地 demo 一切正常、接进真实语料后页面上却出现五段差不多的话。
Weaviate 官方发布博客对这件事的描述可以直接引用(原句):标准向量检索有一个已知的副作用,它会把近重复项聚到一起——一条「意大利菜」的查询返回五张披萨图,一条 RAG 链路检索回五段内容差不多的话。后面那句结论更重要:光有相关性不够,你还需要多样性。
1.2 该盯哪个数:三个量管三件不同的事
| 量 | 量的是什么 | 能不能看见冗余 | 典型误用 |
|---|---|---|---|
| 单条相似度(Sim(d, q)) | 这一条跟 query 有多像 | 看不见 | 看到第一条 0.968 就认为召回质量没问题 |
| 结果集内部平均相似度 | 这个集合自己跟自己有多像 | 能看见 | 不知道该算,或者拿它当排序分数用 |
| 异主题条数 | 集合里有几条来自不同主题 | 能看见,最直观 | 只报这个数不够,它丢掉了「像到什么程度」 |
三行里最关键的是中间那行:**它是集合量,把「像不像同一篇」从单条身上挪到了整个结果集上。**异主题条数好懂但太粗;而单条得分全在 0.95 以上时,看报表的人最容易以为召回质量没问题——其实它们相关的是同一件事。
二、把这个判据跑成一段代码
这一章把上一章那个判据跑成代码并贴上原样输出。它能跑是因为没有外部依赖:向量合成、只用 numpy,不需要 embedding 服务与向量库,换台机器也能复现同一份数字。
2.1 先声明口径,否则这 14 篇的数字没法用
- 候选池:14 篇。A 组 8 篇由「簇中心 + 0.03 倍噪声」生成,B/C/D 三个主题各 2 篇由各自簇中心(+0.05 倍噪声)生成,全部归一化。
- 向量维度:32 维合成向量,随机种子固定。
- query:A 簇中心 + 0.05 倍噪声,再归一化——也就是查询本身就问的是 A 那个主题。
- 冗余度定义:结果集内部两两余弦相似度的平均(取上三角、不含对角)。
- 异主题条数定义:结果集里名称不以 A 开头的条数,上限 5。
- k:固定取 5。
把「冗余度」定义在集合上而不是单条上,是这一章唯一需要你照着做的一步。如果你的报表里没有这一列,那么无论 MMR 有没有生效,你都看不出来。
2.2 实测块 1:核心逻辑与真实输出
🧪 实测环境:Python 3.13.12 / macOS / numpy 2.5.4
下面是脚本的核心两段(节选,完整脚本含合成向量构造部分):
defmmr(k,lam):sel,cand=[],list(range(len(pool)))whilelen(sel)<kandcand:ifnotsel:best=max(cand,key=lambdai:sim[i])else:best=max(cand,key=lambdai:lam*sim[i]-(1-lam)*max(float(pool[i]@ pool[j])forjinsel))sel.append(best);cand.remove(best)returnseldefredundancy(sel):v=pool[sel];m=v @ v.T iu=np.triu_indices(len(sel),k=1)returnfloat(m[iu].mean())forlamin(0.3,0.4,0.5,0.6,0.7,0.9):sel=mmr(k,lam)print(f"{'MMR lam='+str(lam):<16}{str([names[i]foriinsel]):<42}{redundancy(sel):>12.3f}"f"{sum(1foriinselifnotnames[i].startswith('A')):>10}")原样输出(含 λ 扫描表):
候选池 14 篇:主题 A 近重复 8 篇 + 其他 6 篇,取 top-5 策略 结果集 内部平均相似度 异主题条数 ---------------------------------------------------------------------------------- 纯相似度 ['A1', 'A4', 'A3', 'A6', 'A5'] 0.978 0 MMR lam=0.3 ['A1', 'B1', 'C2', 'D1', 'A6'] 0.092 3 MMR lam=0.4 ['A1', 'B1', 'C2', 'D1', 'A6'] 0.092 3 MMR lam=0.5 ['A1', 'B1', 'A6', 'A4', 'A3'] 0.530 1 MMR lam=0.6 ['A1', 'A6', 'A4', 'A3', 'A2'] 0.977 0 MMR lam=0.7 ['A1', 'A4', 'A3', 'A6', 'A2'] 0.977 0 MMR lam=0.9 ['A1', 'A4', 'A3', 'A6', 'A5'] 0.978 0 纯相似度结果里,来自同一主题 A 的条数:5/5 → A 组内部两两相似度都 >0.95,对读者来说等于同一篇。 → lam 调大(更看重相似度)会退化回纯相似度,去冗余就失效了。这份输出里有两处要单独指出,否则很容易读漏。
**第一处,λ=0.3 与 λ=0.4 结果完全一样。**不是巧合,也不是打印问题:第二位往后谁被选中是离散跳变,λ 在小范围内挪动不改变任何一次「谁赢」。别指望把 λ 从 0.35 微调到 0.40 能看到变化。
**第二处,λ 从 0.5 到 0.6,内部平均相似度从 0.530 跳到 0.977,一步跳到顶。**这不是渐变,是塌方。第 4 章会把这个跳变点算出来。
「结果集」那一列值得单独读一遍:λ=0.3/0.4 时 5 条里 3 条来自 B/C/D,λ=0.5 只剩 1 条,λ=0.6 之后一条都不剩;第一位永远是 A1。MMR 只动第 2 名往后。
三、这段代码里其实只有 3 件事
上面那段代码看着绕,拆开只有三件事,而且三件事各自对应一个会踩的坑。把这三件事分清楚,比记住 MMR 的公式有用得多。
3.1 第一件:第一条直接照抄纯相似度的第一名
if not sel: best = max(cand, key=lambda i: sim[i])这一行没有 λ 参与。已选集合为空时,MMR 的公式退化成「只看相关性」,所以第一名与纯相似度一模一样。Weaviate 官方概念文档把这条写成了显式结论:结果按 MMR 分数排序而不是按 query 相似度排序,第一位永远是最相关的那一条。
**顺带的后果是:单看 top-1 永远发现不了 MMR 有没有生效。**你必须在 k≥3 的位置看结果集,才看得出差异。
3.2 第二件:惩罚项取的是「与已选集合的最大相似度」,不是平均
这是整段代码里唯一容易写错的地方。max(float(pool[i] @ pool[j]) for j in sel)取的是 max。取 max 的含义是:候选只要跟已选里的任何一条撞了,就要付出全额惩罚,撞多条也不会重复扣。
写成 mean 就完全不同:候选跟 4 条已选项都撞 0.97 时,惩罚被摊薄到 0.78 左右,那条近重复还有机会挤进来。「撞到就罚」与「撞的人多才罚得多」的差别,就是 MMR 和「给结果打个多样性折扣」这类土办法的差别。
3.3 第三件:冗余度必须是集合内部的量,跟单条得分无关
redundancy()只吃结果集的下标,返回一个数。它不读sim,也就是说它完全不关心每条跟 query 有多像。这三件事合起来构成一个闭环:前两件决定「选谁进来」,第三件决定「选完之后怎么验收」,而验收用的量跟选择用的量不是同一个量。
**所以「结果集内部平均相似度」不能拿来当排序分数用,也不能拿来和单条相似度比大小。**它唯一的用途是作为一条回归检查:改完参数跑一遍,这个数该降还是该升,本来就该由你来定。
四、λ 一调大,MMR 就退化回纯相似度
上表里 λ≥0.6 的三行,异主题条数全是 0、内部平均相似度 0.977~0.978,与纯相似度的 0.978 基本重合。λ 一旦调过临界点,MMR 就在做纯相似度排序,去冗余完全失效——参数还在,功能没了。
4.1 实测块 2:λ 扫描表的完整口径
🧪 实测环境:Python 3.13.12 / macOS / numpy 2.5.4;合成向量模拟、取数日期 2026-10-13
| 策略 | 异主题条数 | 内部平均相似度 | 与纯相似度(0.978)的差 |
|---|---|---|---|
| 纯相似度(不开 MMR) | 0 | 0.978 | —— |
| MMR λ=0.3 | 3 | 0.092 | −0.886 |
| MMR λ=0.4 | 3 | 0.092 | −0.886 |
| MMR λ=0.5 | 1 | 0.530 | −0.448 |
| MMR λ=0.6 | 0 | 0.977 | −0.001 |
| MMR λ=0.7 | 0 | 0.977 | −0.001 |
| MMR λ=0.9 | 0 | 0.978 | 0.000 |
口径:候选池 14 篇(同一主题近重复 8 篇 + 其他 3 个主题各 2 篇),k=5,冗余度取结果集内部两两余弦相似度的平均(上三角、不含对角),全部为合成向量模拟。取数日期 2026-10-13。
这张表不需要读第二遍,只需要看最后两列:**λ≥0.6 时你花在 MMR 上的算力,换回来的是与纯相似度差 0.001 的结果集。**0.001 这个量级说明它已经彻底不工作了。
4.2 临界点不是 1.0,而是 0.52:把不等式写出来
官方文档把 λ=1.0 标成「等价于标准向量检索」,那是数学上的端点;但在真实候选池里,塌方远在 1.0 之前就发生了。这一步可以手算,不用跑代码。
第二位要决出胜负时,A 组候选(记 s_A 为它与 query 的相似度,r_A 为它与已选第一条的相似度)对上其他主题候选(s_B、r_B)。A 组胜出的条件,以及整理后的翻盘点:
λ · s_A − (1 − λ) · r_A > λ · s_B − (1 − λ) · r_B λ* = (r_A − r_B) / [ (r_A − r_B) + (s_A − s_B) ]把同一候选池上的量代进去(取数日期 2026-10-13):A 组内部两两平均余弦 0.9769,A4 与 A1 的余弦 0.9779,B1 与 A1 的余弦 −0.1704,A4 对 query 0.9641,B1 对 query −0.0938。代入得λ= 0.5205*。
这个数正好落在扫描表里「0.5 还能翻、0.6 翻不动」之间,两处结果对得上。**分母里 (s_A − s_B) 是「近重复主题对 query 的优势」,分子里 (r_A − r_B) 是「近重复主题对已选项的劣势」。前者越接近后者,临界点越靠近 0.5。**语料里近重复越多、越像,s_A 越大;近重复文档之间越像,r_A 也越大——两者同步上升,于是「λ 稍微调大一点就翻盘」成为常态,λ 的可用窗口被压得很窄。
很多人第一次用 MMR 会得出「这东西没用」:他们把 λ 从 0.5 调到 0.7,看着结果没变就放弃了。不是 MMR 没用,是 0.7 已经在临界点另一侧。
4.3 独立信源对得上:λ 的两端语义是公开写明的
LangChain 对lambda_mult的官方描述是(逐字):它是 0 到 1 之间的数,决定结果的多样程度,0 对应最大多样性、1 对应最小多样性,默认值 0.5。同页还给了fetch_k,默认 20,作用是「取多少篇文档交给 MMR 算法」——候选池大小是单独一个参数,这一点第 5 章还会用到。
Weaviate 官方概念文档把同一件事写成三档(逐字):λ=0.0 是纯多样性,最大化与已选项的差异;λ=0.5 是平衡,每条结果既要相关又要不同;λ=1.0 是纯相关性,等价于标准向量检索。它的搜索页还补了一句关键的:结果按 MMR 分数排序而不是按查询相似度排序,第一条永远最相关,后续结果可能查询相似度更低,因为它们是靠「带来差异」被选上的。
原论文(Carbonell & Goldstein,SIGIR’98)摘要里的原句是:MMR 准则力图在重排检索文档、挑选摘要段落时降低冗余,同时保持与 query 的相关性;正文对「边际相关性」的定义是:一份文档具有高边际相关性,当且仅当它既与 query 相关,又与已选文档的相似度最小。这条定义就是第 3 章第二件事的出处——惩罚项针对的是「与已选项的相似度」,而不是别的什么。
一份「MMR 参数与验收清单」:λ 取值对照、结果集冗余度算法、临界点算式整理成一页,配本章代码用。放在资料包里,扫码即可获取:
五、阈值就取 0.5,并且只看结果集那一列
**结论先给:默认 λ 取 0.5,验收只看结果集内部平均相似度;如果你把一个池子的 λ 调到 0.6 以上而「异主题条数」还是 0,那不是语料的问题,是参数的问题。**下面给出分档、接入位置和停手条件。
5.1 分档表:λ 落在哪一段,做什么决定
| λ 区间 | 结果集现象(本池实测) | 该做什么 | 不该做什么 |
|---|---|---|---|
| λ ≤ 0.4 | 异主题 3 条,内部平均 0.092 | 只在「用户明确要看广度」时用 | 别当 RAG 问答默认值,相关性让得太多 |
| 0.4 < λ ≤ 0.55 | 异主题 1 条,内部平均 0.530 | RAG 问答的默认区间 | 别在这个区间里继续微调,结果会离散跳变 |
| λ ≥ 0.6 | 异主题 0 条,内部平均 0.977 | 等于没开,回去检查参数 | 别写成「已启用 MMR 多样性重排」 |
第三行是这张表存在的理由:**λ≥0.6 时配置项还在、算法还在跑,只有效果没了。**这种静默失效比报错难查——没有异常、没有日志,结果集悄悄变回原样。
5.2 把检查接进检索流水线
⚠️代码待验证
下面这段没有在真实向量库上跑过(本机没有部署向量服务),只给出接入位置与参数口径;k/fetch_k/lambda_mult三个参数名与含义来自 LangChain 官方 API 文档(见附表 A)。
hits=store.max_marginal_relevance_search(query=question,k=5,# 最终进上下文的条数,等价于本文的 kfetch_k=40,# 先放大候选池,MMR 才有得挑;官方默认 20,建议取 2~4 倍的 klambda_mult=0.5,# 越小越多样;0=纯多样性,1=纯相关性)fetch_k是最容易被忽略的一个:**候选池小于就等于没有选择余地。**如果 fetch_k 只给 5,那 MMR 面对的就是已经定死的 5 条,只能原地重排;近重复已经占满 5 个名额时,怎么排都是它们。
5.3 什么时候该停手
**第一条停手线:候选池没放大之前,不要调 λ。**先把 fetch_k 提到 k 的 2~4 倍,再看 λ 有没有效果。跳过这一步就调 λ,你会把「池子太小」误判成「MMR 没用」。
**第二条停手线:结果集内部平均相似度本来就在 0.5 以下的语料,不要开 MMR。**这类语料本身就分散,λ=0.5 时只有 0.1~0.2 的下降空间,代价却是有可能把一条真正有用的同主题补充材料挤出去。先量一遍不开 MMR 时的内部平均相似度,低于 0.5 就先修语料与分块。
**第三条停手线:λ 调到 0.6 以上仍无变化时,停手去查配置,不要一路加到 1.0。**0.9 与 0.6 在本池里结果一模一样,再加上去只是在重复确认已经确认过的事。
最后一条是给验收用的:**报告里必须同时有「异主题条数」和「结果集内部平均相似度」两列,只有一个都不够。**异主题条数会在 λ 刚好跨过临界点时从 3 直接掉到 0,看不出中间那一步;内部平均相似度会在同一刻从 0.530 跳到 0.977,两者一起看,才能定位到你到底在临界点的哪一侧。
这一篇用到的脚本与验收清单:合成向量脚本、λ 扫描输出、临界点算式都在里面,换成你自己的语料就能跑。放在资料包里,扫码即可获取:
附表 A:本文引用事实与出处对照表
| # | 事实 / 表述 | 出处(文档名 + 发布方 + 链接) | 本文位置 |
|---|---|---|---|
| A1 | 论文摘要原句:MMR 准则在重排检索文档、挑选摘要段落时降低冗余(reduce redundancy)同时保持与 query 的相关性 | The Use of MMR, Diversity-Based Reranking for Reordering Documents and Producing Summaries,Carbonell & Goldstein,SIGIR’98,pp.335-336,ACM DOI https://doi.org/10.1145/290941.291025 ;作者单位托管 PDF https://www.cs.cmu.edu/~jgc/publication/The_Use_MMR_Diversity_Based_LTMIR_1998.pdf | 1.2、4.3 |
| A2 | 正文对边际相关性的定义原句:与 query 相关且与已选文档相似度最小的文档,边际相关性高 | 同 A1(SIGIR’98 论文正文) | 3.2、4.2 |
| A3 | 论文说明该权重取 1 时退化为标准相关度排序、取 0 时得到结果集内的最大多样性排序,中间取值是两个准则的线性组合(作者单位托管 PDF 的文本抽取层把该希腊字母渲染成 X,对应各实现里的 λ) | 同 A1(SIGIR’98 论文正文) | 4.2 |
| A4 | lambda_mult官方语义:0 到 1 之间的数,0 对应最大多样性、1 对应最小多样性,默认 0.5;fetch_k默认 20,含义是交给 MMR 算法的文档数 | LangChain API 文档《max_marginal_relevance_search》(VectorStore / InMemoryVectorStore),LangChain,https://reference.langchain.com/python/langchain-core/vectorstores/base/VectorStore/max_marginal_relevance_search | 4.3、5.2 |
| A5 | 官方三档原句:λ=0.0 纯多样性;λ=0.5 平衡;λ=1.0 纯相关性,等价于标准向量检索;且结果按 MMR 分数排序、第一条永远最相关 | Weaviate 官方文档《Vector Search》概念页,Weaviate,https://docs.weaviate.io/weaviate/concepts/search/vector-search | 3.1、4.3、5.1 |
| A6 | 官方博客原句:标准向量检索有把近重复项聚到一起的已知副作用(「意大利菜」返回五张披萨图、RAG 检索回五段内容差不多的话),仅靠相关性不够、还需要多样性;截至 2026-10-13,该博客标注 MMR 多样性选择自 v1.37 起为预览能力、v1.39 起转为正式可用 | Weaviate 发布博客《Weaviate 1.37 Release》与《Weaviate 1.39 Release》,Weaviate,https://weaviate.io/blog/weaviate-1-37-release 、https://weaviate.io/blog/weaviate-1-39-release | 1.1、4.3 |
| A7 | λ=0.3/0.4/0.5/0.6/0.7/0.9 六档与纯相似度基准的结果集、内部平均相似度、异主题条数(纯相似度 0.978/0;λ=0.3 与 0.4 均为 0.092/3;λ=0.5 为 0.530/1;λ≥0.6 为 0.977~0.978/0) | 本文实测,合成向量模拟(14 篇候选、32 维、k=5),Python 3.13.12 / macOS / numpy 2.5.4,取数日期 2026-10-13 | 2.2、4.1 |
| A8 | 临界点算式 λ* = (r_A − r_B) / [(r_A − r_B) + (s_A − s_B)],代入同一候选池得 λ* = 0.5205,与扫描表「0.5 可翻、0.6 不翻」一致 | 本文按同一候选池计算,取数日期 2026-10-13 | 4.2 |
说明:A1–A3 取自 SIGIR’98 论文(ACM DOI 页面已核,该页标注「No abstract available」,原句取自作者单位托管的 PDF 版本,该 PDF 文本层存在个别识别噪声,本文只引识别无误的片段)。A4–A6 取自 LangChain 与 Weaviate 的官方文档与官方发布博客,A7–A8 为本文实测与本文计算。候选池为合成向量,绝对数值不可与你的真实语料直接比较,可比的是趋势与判据。
写在最后:这篇用到的资料
写这篇文章时,把相关的官方文档和源码又翻了一遍,顺手也整理了几份配套的东西:
- 大模型学习路线图:从零基础到能自己动手做 Agent,按阶段说明每一步该学什么、哪些可以先跳过
- 《LangChain + LangGraph + MCP 智能体开发实战》视频课:7 个模块,从私有化部署、Embedding+RAG 到 MCP+Agent 全流程
- AI 大模型知识库(在线可查):Agent Skills 从入门到落地、Claude Skills 完全指南等专题,按目录浏览即可
- 640 套 AI 大模型行业报告 + 经典 PDF 书籍:看行业落地案例和别人怎么做的时候用得上
- 大模型零基础到精通教学视频:跟着敲一遍,比只读文档快得多
资料是我自己整理的,放在下面这个码上,扫码即可获取:
添加时备注「AI」,优先通过。
资料按「先路线、再动手、最后查漏」的顺序整理好了,建议先看学习路线那一份,照着它挑一条适合自己当前基础的路径再往下看。