用混合检索与重排构建有上下文的客服 Agent
教程基于 MongoDB Atlas,将向量检索与全文检索通过 RRF 融合,再用交叉编码器重排,为模型提供回答依据。文章进一步讨论客服多轮对话中的指代、账户数据和操作权限问题。
完整中文译文
本教程由 Samuel Molling 撰写。
从外部看,客服智能体似乎很简单:一个聊天框,用户在里面提问,然后得到答案。然而,在这个界面背后,工作并没有那么简单。答案可能藏在产品文档里,也可能在几个月前另一位客服解决的工单里,或是客户自己的账户数据中;还有些问题,智能体无权自行处理。
本实验的核心是一条基于 MongoDB Atlas 构建的检索增强生成(RAG)流水线。系统先将问题转换为嵌入向量,再通过 Atlas Vector Search 检索文档分块,同时使用 Atlas Search 从完整文章中检索词面匹配结果。两组排序列表通过倒数排名融合(Reciprocal Rank Fusion)合并,再用交叉编码器对最有潜力的候选结果重新排序,最后将最佳段落作为上下文传给 LLM,用于生成答案。
当输入是一条信息完整、可独立理解的搜索查询时,这条流水线表现很好。但客户支持场景有所不同。
客户很少会一次就提出一个完全独立、信息完整的问题。他们会先问一个问题,再补充说明,引用之前的回答,询问自己账户的情况,有时还会提出智能体根本不该处理的请求。
看看下面这段对话:
你们的产品支持 SAML SSO 吗?
Pro 套餐可以用吗?
那要多少钱?
最后一个问题几乎无法用作检索查询。里面没有产品、功能或套餐名称。它的含义依赖于前面的对话。
而检索只是问题的一部分。在一次客服会话中,一个答案可能来自文档,另一个来自已解决的历史工单,还有一个来自客户业务数据。有时,正确的下一步根本不是给出答案,而是转交人工。
本文将围绕这条检索流水线,构建客服智能体所需的各个层次:查询改写、对话记忆、多个语料库、客户权益、业务操作工具、置信度,以及转人工机制。
控制台以 Enterprise 客户身份作答的示例。检查面板展示了凝练后的问题、分类、置信度区间,以及经过融合和重排序后保留下来的五个段落。

当查询变成对话,会发生什么变化
工作的基本单位,从用一个段落回答一条查询,变成了解决整段对话中的问题。这听起来像是用户体验的变化,但它同时也是数据建模、安全和控制流的变化。
考虑真实对话中的第四轮:“那要多少钱?”原文只有五个词。没有产品,没有套餐,也没有值得做嵌入的动词。如果原样发送到向量索引,检索出来的就会是噪声,因为用于计算向量的这句话,单独看毫无意义。问题表现为检索效果不佳,于是工程师跑去调整 numCandidates。
现在再看客户在一次会话中真正需要什么。第一轮的答案在文档里。第二轮的答案在八个月前一张已解决的工单里,因为已经有人解决过完全相同的问题,并把过程记录了下来。第三轮的答案是订单 ORD-1042 的发货状态,这条信息存在业务集合中,根本不是检索问题。第四轮的情况是,这位客户想取消服务,而任何语料库中的任何段落,都不能授权智能体处理这件事。
四轮对话,四种不同的数据来源,其中一种是人。检索流水线只能处理其中一种。
本实现中,每一轮的完整流程如下:
智能体单轮处理的端到端流程。首先执行问题凝练,这一步可以直接跳过检索。是否转人工会在生成答案之前评估,而不是之后。

第 2 和第 3 阶段在存储对话记录的同一个 Atlas 集群上运行。这一点比听起来更重要,后面我会再谈到。
问题凝练:只改写,不回答
在对一轮输入执行检索之前,必须先将它改写为可独立理解的问题。一次 JSON 模式的 LLM 调用会同时完成输入改写和分类,因为两者需要完全相同的上下文,再增加一次往返调用只会让每轮延迟翻倍,却不会带来新信息。
{
standaloneQuestion: "How much does SAML SSO cost?",
intent: "billing_question",
needsRetrieval: true,
needsTools: [],
sentiment: "neutral",
mentionedOrderId: null
}
提示词中有意思的部分不是要求它做什么,而是禁止它做什么。
一个不受约束的改写器,就是一台高效的事实编造机器。在一段讨论 SSO 的对话之后,给它一句“那要多少钱?”,它很乐意生成:
一个有 50 个席位、按年付费的团队,在 Pro 套餐上使用 SAML SSO 要多少钱?
这里出现了客户从未说过的三个约束:套餐、席位数、计费周期。每一个约束都会缩小索引中的候选结果集。原本能够回答问题的段落,现在已经无法检索到,而日志里没有任何信息表明查询是编造出来的。你看到的,只是一个看似合理、却返回了糟糕结果的问题。
过度凝练会悄无声息地失败,看起来就像检索出了问题。因此,系统提示词用来说明“不要做什么”的篇幅,比描述任务本身还多:
- Do NOT answer the question. You are not the support agent.
- Do NOT add any constraint, number, product name, plan name, version,
quantity, or qualifier that the customer did not actually say.
- Do NOT resolve ambiguity by guessing. If "it" is genuinely unclear, keep
it vague rather than picking a referent.
- DO resolve pronouns and elisions that the earlier turns make unambiguous.
needsRetrieval 是这次调用的另一半职责,它决定是否执行整个检索阶段。当客户输入“谢谢,问题解决了”时,分类器会返回 false,这一轮就会跳过嵌入计算、两次向量搜索、一次重排序,以及在提示词中放入五个段落的步骤。对于一个朴素实现的智能体,每轮都运行完整流水线,是单轮成本的主要来源,而真实客服对话中,有相当一部分轮次只是确认或致谢。
JSON 模式保证生成语法有效的 JSON,但不保证结构符合要求。在下游读取任何字段之前,系统都会将其约束到一个封闭的取值集合中:
const intent = INTENTS.includes(obj.intent) ? obj.intent : "other";
如果一个无法识别的意图漏了过去,它就可能绕过转人工策略,甚至根本没有接受该策略的检查。约束取值不只是一种防御式编程风格,它是防止分类器幻觉演变为策略绕过的关键。
三层记忆
在这个系统中,对话状态是这样一个部分:最直观的数据模型,会同时在三个方面出问题。
最直观的模型,是把消息放在对话文档的一个数组里。这样可以保持数据局部性,一次 findOne 就能返回全部内容,数据结构也与应用中的结构一致。但它会逐渐暴露以下问题。
每次追加消息都会重写整个文档。到了第 40 轮,为了增加一行内容,需要重写前 39 轮的文本,因此写放大会随对话长度线性增长。工作集的大小取决于最长的对话,而不是中位长度的对话,因为对该对话的任何一次读取,都会将所有轮次加载到缓存中。只要有一个客户一直不关闭聊天组件,就会推高整个集合的内存占用。此外,还有一道 16MB 的硬性上限。这道上限看似遥远,但只要用户足够坚持,终究会碰到;到那时,一条普通消息就会触发未处理的写入错误。
消息存放在独立的集合中:
messages: {
_id, conversationId, seq, role, content, meta: {}, createdAt
}
有了 { conversationId: 1, seq: -1 } 索引,读取“最近 K 轮”只需要进行索引扫描,而追加操作始终保持常数时间,不受对话长度影响。
在对话记录之上,还有另外两层,两者都存放在对话文档中:
conversations: {
_id, customerId, status,
summary, // rolling prose digest
facts, // extracted identifiers
turnCount, failedTurns, lastIntent,
createdAt, updatedAt, expiresAt?
}
发送给模型的上下文窗口由摘要、最近 K 轮的原文和事实信息组成。这部分预算是固定的。如果每次都重放完整对话记录,成本和延迟就会随对话长度增长,这恰恰适得其反,因为最长的对话往往正是那些已经不太顺利的对话。
摘要每 6 轮重新生成一次,而不是每轮都生成。每轮都做摘要,会让 LLM 调用次数翻倍,却只是在重复表达变化很慢的信息,因为第 1 到第 20 轮的摘要,与第 1 到第 21 轮的摘要几乎一样。原文窗口会覆盖摘要尚未吸收的内容。
之所以要有 facts 数组,是因为摘要器从设计上就是一个改述工具。让模型压缩一段对话,到第十五轮左右,它就可能在某处把“ORD-1042”润色成“客户最近的订单”。摘要器的提示词要求逐字保留每一个标识符、错误代码、产品名称和明确提出的约束,但即便如此,我也不会拿一次客户交互去赌它能做到。订单 ID 能在结构化层中保留下来,因为这一层没有任何机制试图让文字读起来更顺畅。
让会话过期,但保留客户记录的 TTL
有一个索引值得单独解释:
{ key: { expiresAt: 1 }, name: "conversations_ttl_anonymous", expireAfterSeconds: 0 }
TTL 索引只会让包含被索引字段的文档过期。不含 expiresAt 的文档会一直被忽略。而 expiresAt 只会在一个地方写入:匿名访客开始会话时。
因此,这条规则表达的是:匿名会话是缓存,已登录客户的对话则不是。
给所有对话统一设置 TTL,看起来更整洁,实际上却是在缓慢丢失数据。客户的支持历史是运营数据,涉及保留策略、合规要求,还关系到“你们三月份跟我说了什么”这类问题。按滚动的 30 天窗口删除这些数据,会悄无声息地逐份销毁记录,没有报错,也没有备份。直到有人索要三月份的对话,你才会发现。
两个语料库:文档与已解决工单
文档描述的是产品的设计行为,工单描述的是用户实际经历的行为。这是两个不同的语料库,第二个不可或缺,因为支持工作真正发生的地方,正是两者的表述鸿沟。
文档会这样描述一个错误:当身份提供商提交的 SAML 断言中所携带的 Audience URI 与工作区配置的 Entity ID 不匹配时,系统会返回 AUTH_302。
客户则会这样报告:我用 Okta 登录后,又被跳回了登录页面。 这两段文字几乎没有共同词汇。由解决问题的支持人员撰写的已解决工单,恰好处在两者之间:它用症状来描述问题,并以解决方案收尾,真正能匹配客户措辞的就是它。
因此,这条流水线会对两个语料库运行三个排序器,再融合结果:
weights: {
kbChunks: 1.0,
kbArticles: 0.7,
tickets: 0.5,
}
融合采用倒数排名融合(Reciprocal Rank Fusion,RRF),并为每个排序器设置权重。RRF 根据排名融合,绝不使用原始分数,因为 BM25 没有上界,而余弦相似度的取值范围是 [0,1]。把两者相互归一化,意味着凭空发明一套没人能给出合理依据的换算规则。
score(doc) = Σ over rankers weight_r × 1 / (k + rank_r(doc)) // k = 60
MongoDB 8.1 提供了原生的 $rankFusion 阶段。本实验手动实现 RRF,是为了展示计算过程;在 8.1 及以上版本的生产环境中,应优先使用该阶段。
工单排序器有两项文档排序器没有的调整。
时效衰减。十八个月前的解决方案,描述的可能是一个已经不存在的控制台页面。工单的分数贡献每隔 180 天减半:
export function recencyDecay(resolvedAt, now = new Date()) {
if (!resolvedAt) return 0.5; // unknown age: assume one half-life
const ageDays = (now.getTime() - new Date(resolvedAt).getTime()) / 86_400_000;
if (!Number.isFinite(ageDays) || ageDays <= 0) return 1;
return Math.pow(0.5, ageDays / tuning.ticketHalfLifeDays);
}
没有其他匹配结果时,旧工单依然可能胜出。只要存在较新的文档,它就会排在旧工单前面。
在索引中强制执行的质量门槛。只有 status: "resolved" 且 quality >= 0.7 的工单才有资格参与:
filter: {
status: { $eq: "resolved" },
quality: { $gte: tuning.ticketMinQuality },
...(customer.locale ? { locale: { $eq: customer.locale } } : {}),
如果工单语料库包含尚未解决的讨论或错误答案,它就会变成一台大规模复现历史错误的机器,而且还带着已结案工单那种笃定的口吻。这两个条件都作为向量索引的过滤字段,而不是放在下游的 $match 中,原因将在下一节说明。
对改写后的问题做嵌入,对原始问题做重排序
有一个微妙之处,我花了一段时间才看清。
用于生成嵌入的是凝练后的问题。这正是凝练的意义所在,因为向量必须来自一个语义完整、无需依赖上下文的句子。
重排序器接收的则是当前轮次的原始消息。
const rerankQuery = originalMessage?.trim() || condensedQuestion;
const ranked = await rerank(rerankQuery, docs, tuning.topPassages)
改写是一种针对嵌入相似度优化的有损转换。它会舍弃语气、紧迫程度和客户自己的措辞,生成一句干净、便于检索的句子。这适合作为近似最近邻索引的输入,却不适合用于判断相关性。重排序器是一个交叉编码器,会将查询和段落一起读取,因此它应该看到客户实际写下的内容,包括那些被改写过程丢弃的词语。
把改写后的问题同时传给两个阶段,是一种鲜有人质疑的默认做法,但它会悄悄降低精确率,而且恰恰发生在客户表述最具体的那些轮次。
访问权限应在检索阶段落实
这是一条授权边界,而不是一条提示词指令。
绝不能根据仅供 Enterprise 套餐使用的操作手册,回答 Free 套餐客户的问题。这是一项授权要求,它有一种正确实现,以及两种常见的错误实现。
正确实现是在向量索引内部求值的过滤字段:
export function entitlementFilter(customer = {}) {
const filter = {
audience: { $in: audienceFor(customer.plan) },
status: { $eq: "published" },
};
if (customer.locale) filter.locale = { $eq: customer.locale };
return filter;
将它作为 filter 传给 $vectorSearch,意味着 Enterprise 段落从一开始就不会被评分。不是评分后再隐藏,而是根本不评分,因此它不会进入候选集,不会到达重排序器,也就不可能出现在提示词中。
以 Free 套餐客户的身份提出同一个问题。参与评分的文本块从二十二个变成了十七个,审计日志文章没有出现,剩余段落中得分最高的是一条关联不大的工单,分数为 0.563——相关性较弱,并不强。

第一种错误实现是提示词指令:“只使用客户有权查看的段落。”这不是控制措施,而是一项请求。请求的对象是一个以顺从为职责的组件,而且在执行这项请求之前,受限文本就已经进入上下文窗口了。一旦它出现在提示词中,距离进入答案就只差一个巧妙的问题。
第二种错误实现是在 $vectorSearch 之后加一个 $match。这比前一种好一些,但依然不对。limit 由索引应用。事后过滤只会悄悄缩小结果集,而不会改变结果的组成。如果命中的十条结果全都仅限 Enterprise 套餐访问,Free 套餐客户得到的就会是一个空页面:纸面上正确,产品体验却坏了。
BM25 排序器也需要同样的保证,而 $search 没有 filter 字段。限制条件要放进复合查询本身,仍然由索引求值:
compound: {
should: [ /* title, content, tags */ ],
filter: [
{ in: { path: "audience", value: audienceFor(customer.plan) } },
{ equals: { path: "status", value: "published" } },
],
minimumShouldMatch: 1,
}
这里的 in 运算符有一个值得点明的陷阱,因为它曾让我的一个排序器失效,却没有给出任何提示。
in 和 equals 无法匹配以经过分析的字符串类型建立索引的字段。它们会返回 0 条命中结果,而且不报错。应将用于过滤的字段映射为 token:
tags: { type: "string", analyzer: "lucene.keyword" }, // scored, stays a string
audience: { type: "token" }, // filtered, never scored
locale: { type: "token" },
status: { type: "token" },
当 audience 被映射为使用 keyword 分析器的字符串时,复合过滤条件会悄悄排除所有文档,BM25 排序器对每一次查询都返回空结果。向量搜索仍然正常,结果看起来也依然合理,而本节讨论的访问权限保证,在那个排序器上实际上根本没有得到执行。离线测试套件捕捉不到这个问题:它需要真实索引。直到把系统连到集群上运行,并发现其中一个排序器对每一次查询都报告零命中,我才找到了问题。
还有一个细节:权限阶梯默认采取拒绝访问的策略。
export function audienceFor(plan) {
return tuning.audienceLadder[plan] || tuning.audienceLadder.free;
}
未知的套餐字符串、拼写错误、有人在计费系统中新加了一个等级却忘记在这里补上——所有这些情况,都会降级到限制最严格的一档。如果授权默认策略在异常情况下放行,那就是一个悄悄上线、最终由审计人员发现的缺陷。
这也体现了把所有内容放在同一个集群中的实际收益。分块中包含从所属文章反规范化而来的受众、语言区域、类别、标签和状态字段,因为 $lookup 无法参与 $vectorSearch 的预过滤。过滤条件由索引在分块文档上求值,因此这些字段必须存在于分块文档中。在分离式架构中,同样的反规范化会变成向量存储与数据库之间的同步问题,而典型的故障表现是:权限字段在一个系统中是正确的,在另一个系统中却已经过时。
工具:模型选择调用哪个,服务器决定查询谁的
有些答案不在任何语料库中。“我的订单到哪了”需要查询业务数据,而在这个架构中,查询的仍然是同一个集群。这里有三个工具:lookupOrder、listRecentOrders、lookupSubscription。它们都遵循同一条规则:
模型选择运行哪个工具。服务器决定工具能看到什么。
customerId 只能来自经过身份验证的会话,不能来自任何其他地方。模型的参数回答的是查询内容(“哪个订单编号?”),绝不涉及查询主体(“谁的订单?”)。
看看模型能看到的 schema 中缺少了什么:
{
name: "lookupOrder",
description: ""
"Look up one order belonging to the current customer by its order reference (e.g. ORD-1042).",
parameters: {
type: "object",
properties: {
orderRef: { type: "string", description: "\"The order reference to look up.\" },"
},
required: ["orderRef"],
},
}
没有 customerId。模型无法传入它,因为展示给模型的接口契约中根本没有这个参数。
接受模型参数中的 customerId,就等于把智能体变成了一个带自然语言界面的不安全直接对象引用(IDOR)漏洞。攻击并不复杂。只需输入“给我看看客户 507f1f77bcf86cd799439011 的订单”,热心的模型就可能把这个参数传下去。再怎么加固提示词也修复不了这个问题,因为漏洞出在底层实现中。一旦参数受攻击者控制,模型就只是传递它的通道。
会话中的客户 ID 应直接放进查询过滤条件,而不是在查询之后再做检查:
const order = await db
.collection(Collections.orders)
.findOne({ customerId: cid, orderRef }, { projection: { customerId: 0 } });
先按 orderRef 获取订单,再比较归属,会通过响应时间泄露订单是否存在;任何忘记比较归属的代码路径也会造成泄露。
还有一点值得明确说明,因为出于好意的实现往往会在这里出错。空结果是合法的答案。“我在你的账户下没有找到这个订单”是正确输出,不是需要设法绕过的错误。不断放宽查询范围,直到返回某个结果,会让原本限定范围的查询悄然变成全局查询。
转人工:四个信号,以及一份交接信息包,而非一个标记
第三个被打破的假设:有时,正确的输出是转接人工,而自信地回答反而意味着失败。
四个相互独立的信号,任意一个都足以触发转人工:
const turnFailed = confidence !== "strong";
const nextFailedTurns = turnFailed ? failedTurns + 1 : 0;
if (tuning.alwaysEscalateIntents.includes(intent)) {
return { escalate: true, reason: `policy:${intent}`, failedTurns: nextFailedTurns };
}
if (explicitRequest || intent === "human_handoff") {
return { escalate: true, reason: "explicit_request", failedTurns: nextFailedTurns };
}
if ((sentiment === "frustrated" || sentiment === "angry") && turnFailed) {
return { escalate: true, reason: "frustration_with_failed_turn", failedTurns: nextFailedTurns };
}
if (nextFailedTurns > tuning.maxFailedTurns) {
return { escalate: true, reason: "repeated_low_confidence", failedTurns: nextFailedTurns };
}
return { escalate: false, reason: null, failedTurns: nextFailedTurns };
这些信号是有意保持独立的。把它们混合成一个转人工评分,会让系统难以调优:一旦误触发,你无法判断是哪个输入导致了变化,而且每次调整阈值,都会悄然改变这四个信号的相对权重。
第一个信号最容易被忽略。cancellation、refund_request、billing_dispute、data_deletion、legal 这几类意图一经识别就应转人工,无论检索找到了什么。置信度在这里无关紧要,因为即使找到了完全匹配退款政策的段落,也不代表智能体有权执行退款。这些是权限边界,不是知识缺口;在这里给出自信的回答,比不回答更糟。
第三个信号刻意要求两个条件同时成立。仅有挫败情绪不会触发转人工,因为一个心情不悦、但正在获得有效解答的客户,依然得到了帮助。如果客户感到挫败,而这一轮回答也失败了,就说明智能体正在让情况变得更糟;再试一次,只会让客户多一个取消订阅的理由。
第四个信号统计连续失败次数,一次高置信度的回答就会将其清零。客户先得到了一个好答案,接着又提出一个新问题,并不意味着他已经走完了触发转人工所需流程的三分之二。
置信度来自重排序器,而不是单独依据向量距离或 BM25。在预先填充的语料库中,最高重排序得分达到或超过 0.6 时,置信度为 strong;从 0.45 到低于 0.6 时为 weak;再低则为 none。能够回答问题的段落得分约为 0.79 至 0.88,只有间接关联的段落约为 0.56,未经过上下文凝练的对话轮次中检索到的错误内容约为 0.44,语料库范围之外的问题约为 0.33。这些是针对当前语料库校准得到的数值,并非通用阈值。
交接不只是一个状态
status: "escalated" 无法向人工客服提供任何有效信息。他们打开对话,读完十四轮交流,然后把机器人已经问过的问题再问一遍。这正是“我不得不把所有事情解释两遍”背后的体验。
交接信息包就是将智能体已知的信息序列化:
{
reason: "repeated_low_confidence",
intent: "troubleshooting",
askedFor: "Why does SAML sign-in f