找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

5232

积分

0

好友

722

主题
发表于 4 小时前 | 查看: 5| 回复: 0

Milvus向量数据库搜索界面示意图

RAG的团队,基本都在多轮对话上吃过亏。前几轮对话还表现不错,到了第四、五轮,AI就开始重复自己的历史回答内容。比如用户追问细节,系统把已经给过的段落又搜了一遍,换个说法再输出一遍。

其实,问题往往不出在模型本身,而是因为检索系统没有记忆。每一轮对话,它都当作第一次在检索。

这篇文章从阿里通义团队多模态检索框架VimRAG的一个具体发现切入,聊聊多轮RAG里重复召回这个问题,以及如何利用Milvus在工程侧落地有效的解法。

多轮对话后,检索系统在干什么?

RAG的基本链路很简单:用户提问 → 检索文档 → 喂给模型 → 生成回答。在单轮场景下,这个流程没什么问题。

一旦进入多轮对话,情况就变了。用户的第三个问题往往是对第一个问题的追问,第五个问题可能只是换了个角度重问第二个问题。然而,每一轮对话中,检索模块都会重新执行一次搜索,导致同一批文档被反复召回。

这并非偶发现象。阿里通义团队在VimRAG的实践中测试了三种记忆管理方式。传统的“把所有历史对话拼接在一起”的做法,随着对话轮次增加,无效检索次数急剧上升。改成“每轮总结历史”的方式,结果也差不多,因为总结过程会丢失细节,AI依然记不住自己之前搜过什么。

单轮RAG与多轮对话机制对比图

AI没有变笨,只是它没有记忆。这个问题带来的负面影响是多方面的:

  • 用户体验降级:用户最先感知到的是重复。问了三轮,发现AI在绕圈子,对话通常就在那里终止。这种问题通常不会导致系统报错,但会严重影响用户体验。
  • Token成本飙升:VimRAG的对照数据显示:传统方式平均每轮消耗15.8k tokens,而引入选择性记忆后只需2.7k,相差近6倍。多轮对话的复利效应让这个成本缺口持续放大,用户量越大越明显。
  • 回答质量下降:一个反直觉的结论是:重复内容越多,回答质量反而可能越低。模型的注意力是有限的,冗余信息会把关键线索稀释掉,多搜不等于搜得更好。
  • 训练信号污染:最重要的是,在训练RAG系统时,如果不区分哪些检索步骤真正有用、哪些是无效重复,而直接用最终答案的对错来打分,就会出现信号污染。一个正确答案的路径里,可能有一半检索步骤是无效的,但因为最终答案对了,这些无效步骤也拿到了奖励;反之,一个错误答案的路径里,可能有几步检索找到了真正有价值的信息,却因为最后推理出错而被一起惩罚。

这就像考试只看总分不看过程。抄对答案的学生被表扬,认真解题但算错一步的学生被批评。

针对这个问题,VimRAG在训练侧提出了GGPO(图引导策略优化)的解法:用图结构追踪每一步检索的贡献,找到从起点到正确答案的关键路径,只奖励路径上的步骤,屏蔽偏离路径的冗余。基于这个方案,无效检索次数和token消耗都显著下降。

不动模型,怎么在检索层把重复干掉?

VimRAG在推理阶段有自己的去重机制——多模态记忆图(MMG),它用DAG图结构记录每轮推理走过的路径,天然避免重复访问同一信息节点。但MMG需要配套VimRAG的完整训练方案,对于没有微调过模型的团队来说,实现门槛较高。

不过,基于Milvus,我们可以选择三条更简单直接的路径,在检索层就处理掉重复召回的问题。

VimRAG模型级去重与Milvus工程级去重对比图

路径一:expr not in 排除历史ID

这是最直接的做法。维护一个consumed_ids列表,每轮检索后把命中的chunk ID追加进去,下一轮检索时带上expr过滤条件进行排除:

from pymilvus import MilvusClient

client = MilvusClient(uri="http://localhost:19530")
consumed_ids = ["doc_001", "doc_017", "doc_042"]

res = client.search(
    collection_name="rag_chunks",
    data=[query_vector],
    limit=5,
    filter='doc_id not in ["doc_001", "doc_017", "doc_042"]',
    output_fields=["doc_id", "text"]
)

这一层解决的是跨轮次的历史排除——AI在第三轮不会再搜到第一轮已经召回过的文档。

这里需要说明一点:not in在Milvus内部并非逐条比对,而是使用bitset标记被过滤的向量,在HNSW图遍历时直接跳过这些节点。Milvus v2.5.x的版本日志里专门提到了对NOT IN子句的性能优化,包括SIMD加速的bitset操作。但有一个临界点需要注意:当consumed_ids列表很长、过滤率极高时,Milvus会自动从高效的图遍历降级到暴力扫描——此时图索引失效,查询延迟会明显上升。这也是为什么后文会提到需要设置滑动窗口的真实原因,不只是一个经验建议。

路径二:group_by_field 做单次检索内的去重

expr not in能排除历史文档,但解决不了另一个问题:同一篇文档的不同chunk在语义上高度相似,一次检索可能同时命中同一文档的三个段落,这三个chunk会同时出现在结果里,占掉limit=5里的三个名额。

Milvus的group_by_field参数就是用来解决这个问题的。在search()时传入group_by_field="doc_id",Milvus会保证每个文档ID最多返回一个chunk,并自动选取该文档下相关性最高的那个段落:

res = client.search(
    collection_name="rag_chunks",
    data=[query_vector],
    limit=5,
    group_by_field="doc_id",
    output_fields=["doc_id", "text"]
)

这一层是无状态的——不需要维护任何跨轮次的列表,在单次查询时直接保证结果的多样性。

路径三:两者组合,覆盖完整的去重链路

group_by解决单次检索内的文档级重复,expr解决跨轮次的历史重复。将两者组合使用,才是应对多轮对话重复召回场景下比较完整的解法:

res = client.search(
    collection_name="rag_chunks",
    data=[query_vector],
    limit=5,
    group_by_field="doc_id",
    filter='doc_id not in ["doc_001", "doc_017", "doc_042"]',
    output_fields=["doc_id", "text"]
)

第一个参数保证本轮结果里不出现来自同一文档的多个chunk,第二个参数保证历史轮次已经用过的文档不再出现

这两个功能合起来,能覆盖VimRAG在推理侧解决的重复访问问题——但没有覆盖其训练侧GGPO解决的信用分配问题。但这本质上是两件不同的事:一个在改进训练信号,一个在优化检索输入。能用工程手段在推理侧处理的,先用工程手段处理掉,不一定每个团队都需要走到重新训练模型那一步。

真正部署时,这几个地方容易翻车

上一章的三层组合是最小可行方案,但在生产环境落地时,还有几个细节需要仔细考量。

去重记录的粒度怎么选?

使用了group_by_field="doc_id"之后,每次检索最多返回每篇文档的一个chunk。但consumed_ids列表应该记录什么——是doc_id还是chunk_id

两种选择对应两种策略:

  • 记录doc_id:下一轮整篇文档都不会再出现。这适合知识库文档之间独立性强、每篇文档只有一个核心信息点的场景。
  • 记录chunk_id:只排除已经用过的具体段落,同一文档的其他段落下一轮还可以被召回。这适合长文档、每个段落信息密度差异大的场景。

两种都是合理的选择,取决于你的知识库结构。对于大多数企业知识库而言,文档之间独立性较强,记录doc_id通常更安全。

滑动窗口大小需压测后确定

前面解释了not in列表过长时Milvus会从图遍历降级到暴力扫描。因此,我们需要一个滑动窗口,只保留最近N轮对话中消耗的ID。这个窗口大小N值不是一个可以拍脑袋决定的数字——N太小,早期召回过的文档又会出现;N太大,过滤性能开始退化。实际的最佳值需要在你的数据规模和查询压力下通过压测得出,不同场景差异很大。

状态存储别只放内存里

多轮对话通常由无状态的HTTP请求发起,consumed_ids列表不能只存储在应用进程的内存里,否则进程重启数据就丢失了。应该将其存储在Redis等外部存储中,按session_id进行隔离,并在对话结束后设置TTL自动清理。这不是可选的优化,而是方案能够跑通的前提。

什么时候需要关掉去重?

当用户明确表示“再给我看看刚才那个”的时候,就需要临时关掉去重逻辑。这需要在意图识别层做区分——用户是“追问细节”还是“要求提供新信息”,这是两种不同的意图,不能用同一套处理逻辑。

这套方案的覆盖范围

group_by + expr两层组合,解决的是推理侧的重复问题:同一次检索里不出现来自同一文档的多个chunk,跨轮次不重复召回历史文档。

但有一件事它做不到:它不知道某个chunk被召回后,模型是否真正用到了它,更不知道它对最终答案的贡献有多大。VimRAG的MMG能追踪每个检索节点对答案的实际贡献度,在这一层上的精度更高。

如果业务对检索路径的准确性和可解释性要求极高,VimRAG的完整方案值得深入研究。如果优先需要一个改动量小、能快速上线并解决大部分重复问题的解法,那么group_by + expr + Redis这套组合拳已经足够实用。

写在最后

多轮RAG里的重复召回不是一个会导致系统崩溃的显性故障,但它会严重影响用户的实际交互体验和系统运行成本。

在工程实践上,我们的最小改动方案是:给文档向量加ID字段,在search()时带上group_by_field保证单次结果不重复,跨轮次用expr not in排掉历史文档,并将consumed_ids存在Redis里按session隔离。这些改动可以分散在几个地方,每一步都能独立生效,不需要一次全上。

当然,这个方案并非在所有RAG场景中都是必须的。什么时候可能不需要做?比如你的知识库很小、用户绝大多数是单轮交互、或者对话很少超过三轮。在这几种情况下,重复召回的影响可能不足以支撑引入额外状态管理带来的复杂度,可以先不动。等多轮对话占比上来、或者用户留存数据开始出现在第三四轮出现明显断崖式下跌时,再回来做这件事,时机更为合适。




上一篇:教务系统默认口令漏洞实战复盘:从学生账号到超管,160万+敏感信息泄露
下一篇:京东2025年年报股权结构深度解析:刘强东控制权与AB股机制
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-4-23 06:22 , Processed in 1.552080 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表