我的 RAG 问答在单轮提问时召回又准又好可一进多轮对话就拉胯,用户问完一个问题再追问一句它呢或那这个怎么办、检索就召回一片空白或牛头不对马嘴的内容,排查很久才搞懂我直接拿用户那句带着指代和省略的原始追问去做向量检索而那句话脱离了对话历史根本就没承载足够的语义的深度复盘

我做了个基于 RAG 的多轮对话问答,单轮提问表现很好,可一进多轮、用户开始追问检索质量就断崖式下跌:用户先问 XX 产品的退款政策是什么召回很准答得好、接着追问那它的有效期呢检索却召回一片空白或一堆跟有效期八竿子打不着的内容;凡是用了它这个那上面说的这类指代或省略了主语(怎么申请)检索就抓瞎;我手动把追问补全成 XX 产品的退款有效期是多久再检索立刻又召回准;而如果不检索直接把对话历史一起喂给 LLM 它完全懂用户在问那个产品的有效期、理解没问题坏在检索这一步。这几条指向我直接拿用户那句带指代带省略的原始追问去做了向量检索、可那句话脱离对话历史本身根本不含到底在问什么的完整语义(它指谁只有结合上文才知道)。翻原理才懂:向量检索的本质是把当前这条查询文本通过 embedding 映射成向量再去和文档向量比相似度、它能匹配到什么完全取决于这条查询文本自身承载多少语义、它不会也无从知道这条查询之前的对话历史(检索只看你实际传给它的那句文本而非用户在对话语境里真正想问的意思);单轮时两者重合、多轮时严重分裂——人在多轮对话里说话天然高度依赖上下文用指代省略主语、追问的完整语义分散一部分在这句话里一大部分留在历史里、我原封不动拿半句残缺追问去 embedding 检索只能从残缺几个字提取语义、它是无意义指代又没主语向量对不上任何文档于是召回空白或乱召回(LLM 懂是因喂了整个历史能做指代消解、检索只给了孤零零半句)。正解:在向量检索之前先做一步查询改写 query rewriting 上下文化——用 LLM 结合对话历史把带指代带省略的追问重写成一句脱离上下文也能独立理解的语义自包含的检索查询(指代消解+省略补全)再去检索、简单场景也可把最近几轮拼进查询或原始与改写都检取并集。这篇复盘从故障现场讲到检索只认查询自身语义读不到对话历史、直接检索 vs 先改写对照、误区,再到查询改写上下文化的完整正解与改写骨架,以及日志脱离上下文/翻译脱离语境/缓存 key 漏维度/相对路径时间传走/异步任务带不全上下文等同类把依赖上下文的东西丢给不知道上下文的环节的坑,和含义与上下文、信息含义一半在语境里跨语境边界传递前要先把依赖的上下文显式融进去自包含化的认知。

我的 RAG 问答在单轮提问时召回又准又好、可一进多轮对话就拉胯,用户问完一个问题再追问一句它呢或那这个怎么办、检索就召回一片空白或牛头不对马嘴的内容,排查很久才搞懂我直接拿用户那句带着指代和省略的原始追问去做向量检索、而那句话脱离了对话历史根本就没承载足够的语义的深度复盘

这次踩的坑,问题出在一个我从没多想的衔接处:用户嘴里说出来的那句追问,和"能拿去检索的、语义完整的查询",根本不是一回事。多轮对话里,人话是高度依赖上下文的,而检索,要的是一句能独立站住的话。

故障现场:单轮好好的,一进多轮就召回空白

我做了个基于 RAG 的多轮对话问答。单轮提问时它表现很好,可一旦进入多轮对话、用户开始追问,检索质量就断崖式下跌:

  • 追问就召回空白:用户先问"XX 产品的退款政策是什么?",召回很准、答得很好;接着追问"那它的有效期呢?",检索却召回一片空白,或者召回一堆跟"有效期"八竿子打不着的内容。
  • 指代和省略的追问全废:凡是用户用了"""这个""那上面说的"这类指代,或者干脆省略了主语("怎么申请?"),检索就抓瞎
  • 把完整问题打出来又好了:我手动把追问补全成"XX 产品的退款有效期是多久?"再去检索,立刻又召回得又准又好
  • LLM 其实懂用户问啥:有意思的是,如果不检索、直接把对话历史一起喂给 LLM,它完全懂用户在问"那个产品的有效期"——理解没问题,坏在检索这一步

"单轮好、追问废、补全完整就好、LLM 自己懂"——这几条合起来,把矛头指向了一个我完全没处理的环节:直接拿用户那句带着指代、带着省略的原始追问,去做了向量检索;可那句话脱离了对话历史,本身根本不含"到底在问什么"的完整语义——"那它的有效期呢?"里的""指谁,只有结合上文才知道。我得去搞清楚,向量检索到底是拿什么去匹配的。

第一件事:搞懂向量检索靠的是查询文本自身的语义,它不懂对话历史

带着"追问脱离上下文就废"这条线去想检索的原理,我才算真正理解了一件被我想当然跳过的事——向量检索的本质,是把"当前这条查询文本"通过 embedding 映射成一个向量,再去和文档向量比相似度;它能匹配到什么,完全取决于这条查询文本自身承载了多少语义;它不会、也无从知道这条查询之前的对话历史。

关键就在这里:检索看到的,只有你实际传给它的那一句查询文本,而不是"用户在整个对话语境里真正想问的意思"。这两者在单轮时是重合的(用户一句话就把意思说全了),可在多轮对话里,它们严重分裂:

  • 人在多轮对话里说话,天然高度依赖上下文——用指代("")、用省略(不重复已经说过的主语),因为"对方记得前面说了啥";
  • 所以用户的追问"那它的有效期呢?",它的完整语义是分散的:一部分在这句话里("有效期"),一大部分(""=XX 产品、在问退款)留在了对话历史里;
  • 而我原封不动地把"那它的有效期呢?"这句话拿去 embedding 检索,检索系统只能从这残缺的几个字里提取语义——""是个无意义的指代、没有主语,这句话的向量根本对不上任何具体文档,自然召回空白或乱召回。

这下全说通了:不是检索能力不行,而是我喂给它的查询本身就是残缺的、语义不自包含的——它的完整意思一半还躺在对话历史里没被带进来;而检索只认你给的这句话、不会自己去翻历史,于是拿着半句话当然检不到东西。而 LLM 之所以"",是因为我把整个对话历史都喂给了它,它能自己做指代消解;可检索这一步,我只给了它孤零零的半句追问。我把这个差别验证清楚:

# 错误:直接拿用户原始追问去检索(脱离上下文、语义残缺)
user_followup = "那它的有效期呢?"           # "它"指谁?没有上下文根本不知道
docs = vector_search(user_followup)          # 召回空白 / 牛头不对马嘴

# 对照:把追问补全成自包含的完整查询, 再检索
standalone_query = "XX 产品退款的有效期是多久?"  # 语义完整、自己能站住
docs = vector_search(standalone_query)        # 召回又准又好 ✅

# 检索只认你传进去的那句文本的语义, 它不知道也读不到对话历史

真相大白:错在我把"用户在对话语境里说的那句依赖上下文的人话",直接当成了"可以拿去检索的、语义自包含的查询";而向量检索只认查询文本自身的语义、读不到对话历史,半句残缺的追问当然检不到东西。解法的核心,就是在检索之前,先把那句残缺的追问,补全成一句脱离上下文也能独立理解的完整查询

第二件事:正解——检索前先做查询改写,把追问补成自包含的完整查询

根因是"拿语义残缺的追问去检索",那正解的核心就一句话:在向量检索之前,先用一步查询改写(query rewriting / 上下文化)——结合对话历史,把带指代、带省略的追问,重写成一句脱离上下文也能独立理解的、语义自包含的检索查询最常用的做法,就是让 LLM 来做这步改写:

# 正解:检索前插一步"查询改写", 用 LLM 结合历史把追问补全成独立查询
REWRITE_PROMPT = """根据下面的对话历史, 把用户最新的提问改写成一个
不依赖上下文、自己就能独立理解的完整检索查询。只输出改写后的查询。

对话历史:
{history}

用户最新提问: {followup}
改写后的查询:"""

def rewrite_query(history, followup):
    return llm(REWRITE_PROMPT.format(history=history, followup=followup)).strip()

# 多轮 RAG 的正确流程:先改写, 再检索, 后生成
standalone = rewrite_query(history, "那它的有效期呢?")
# -> "XX 产品退款的有效期是多久?"  (指代消解 + 省略补全, 自包含)
docs = vector_search(standalone)          # 拿自包含查询检索, 召回准
answer = llm(build_prompt(docs, history, followup))   # 再生成回答

这套做法的精髓,是在"用户的原始追问"和"拿去检索的查询"之间,插入一个显式的"上下文化"环节:让 LLM 充当那个"记得对话历史"的角色,先把""消解成"XX 产品"、把省略的主语补上,产出一句不依赖任何上下文、自己就能讲清楚要查什么的查询,再交给只认文本语义的检索。这样,检索拿到的就是一句语义完整的话,自然能召回准确的内容。

除了 LLM 改写,还有些配套手段:简单场景可以把最近几轮对话直接拼进检索查询(让历史语义也进入向量);也可以多路召回(原始追问 + 改写后查询都检一遍取并集)。但核心都是一条:别把依赖上下文的原始追问直接丢给检索,要先让它变成自包含的。

第三件事:同一类"把依赖上下文的东西,丢给一个不知道上下文的环节"的坑,我后来又撞见好几个

这次踩坑让我警觉起一个普遍的模式:很多东西的完整含义,一部分在它自身、一部分依赖外部上下文;当你把它交给一个看不到那个上下文的环节去处理时,那个环节只能基于它残缺的自身去理解,自然出错。这种坑不止多轮检索:

  • 日志/报错脱离上下文:把一句"失败了"的日志单独丢给排查的人,没有"什么操作、什么参数"的上下文,根本定位不了。
  • 翻译/摘要脱离语境:把一句带"它/这"的话单独拿去翻译,机器不知道指代谁,翻得驴唇不对马嘴。
  • 缓存 key 漏了上下文维度:缓存 key 只用了部分信息、漏了"用户/租户/语言"等上下文维度,导致张冠李戴。
  • 把相对路径/相对时间传到别处:相对路径、"昨天"这种相对时间,脱离了原来的基准(当前目录、当前日期)传到别处,含义就变了。
  • 异步任务带不全上下文:往消息队列/异步任务里只塞了个 ID,消费方拿不到当时的完整上下文(租户、追踪 ID),处理时信息不足。

它们的内核是同一个:一个信息的"完整含义" = "它自身携带的" + "它所处上下文隐含的";在原始语境里,这两部分天然合在一起,所以它"看起来"是完整的;可一旦你把它从原语境里抽出来、交给一个访问不到那个上下文的环节,那"依赖上下文的那一半含义"就丢失了,接收环节只能拿着残缺的另一半去工作。所以,凡是要把一个东西跨越语境边界传递给一个新环节,都要先问:它的完整含义,是不是依赖了当前上下文?如果是,要先把那部分上下文显式地融进去,让它变成"自包含、脱离原语境也能被正确理解"的。我把这套判断画成了一张图(见后文)。

场景 依赖上下文的东西被直接传走 正确做法(先自包含化)
多轮 RAG 检索 带指代/省略的追问 查询改写成独立完整查询
日志排查 脱离操作上下文的"失败了" 日志带上操作/参数/追踪 ID
翻译/摘要 带"它/这"的句子 先指代消解再处理
缓存 key 漏了用户/语言等维度 把上下文维度纳入 key
异步任务 只塞 ID 不带上下文 把必要上下文随任务带上

第四件事:直接检索原始追问 vs 先查询改写——一张对照表

这次事故逼我把"直接拿原始追问检索"和"先查询改写再检索"摆成一张表,以后做多轮 RAG 前先对照:

维度 直接拿原始追问检索 先查询改写再检索
查询语义 残缺(指代/省略未解) 自包含、完整
单轮提问 没问题(本就完整) 没问题
多轮追问 召回空白/不相关 召回准确
"它/这个"等指代 无法匹配 已消解成具体实体
省略主语的追问 缺主语匹配不到 补全主语能匹配
代价 省一次 LLM 调用 多一次轻量改写、值得

看清这张表,流程就该改:多轮对话的 RAG,检索前必须插一步查询改写,把依赖上下文的追问重写成自包含的完整查询,再去检索;别图省一次 LLM 调用就把残缺的原始追问直接丢给检索。多花的那一次改写,换来的是召回从"空白"到"准确"的天壤之别。

第五件事:我曾经对多轮 RAG 想当然的几个误区

这场"追问就召回空白"的事故,把我对多轮 RAG 的一堆想当然照得清清楚楚:

我以为 实际上
用户问啥就拿啥直接去检索 追问依赖上下文、语义残缺、检不到
检索系统会结合对话历史理解 它只认你传的那句文本、读不到历史
单轮好用多轮自然也好用 多轮的追问高度依赖上下文、会废
LLM 懂用户意图检索就该懂 LLM 懂是因喂了历史、检索只给了半句
召回空白是知识库里没有 可能有、是查询残缺匹配不到
查询改写多此一举、浪费一次调用 它是多轮 RAG 召回准确的关键前置

这些误区的根子是同一个:我默认"用户说出来的那句话"就是"一个语义完整、可以直接拿去检索的查询",完全没意识到,在多轮对话里,用户的话是高度依赖对话上下文的——它的完整意思,有一大半藏在历史里,那句话本身只是个"残缺的、需要上下文才能补全的片段"。正因为我把"对话里的人话"和"自包含的查询"划了等号,我才会把那个依赖上下文的片段,原封不动丢给一个看不到上下文的检索系统。把一个"依赖上下文才完整"的输入,当成"本身就完整"的,直接交给一个无法访问那个上下文的环节,是这类"抽离语境就失效"问题的共同根源。

第六件事:做多轮对话检索、排查"追问召不回"时,我现在的自检习惯

现在每当我做多轮对话的 RAG、或排查"追问就召回空白",我都会先盯住"真正拿去检索的那句查询,语义自包含吗"。先看清原始追问为什么检不到:

然后用这张自检图把多轮 RAG 的流程摆对:

配套地,我把"检索前改写"固化进了多轮 RAG 的标准管线:

# 多轮 RAG 标准管线:rewrite -> retrieve -> generate
def multi_turn_rag(history, user_msg):
    # 1) 上下文化: 把依赖历史的追问改写成自包含查询
    query = rewrite_query(history, user_msg)     # "它"->具体实体, 补全省略
    # 2) 用自包含查询检索(检索只认这句的语义)
    docs = vector_search(query)
    # 3) 生成时再把历史和召回内容都给 LLM
    return llm(build_prompt(history, docs, user_msg))

# 小优化: 单轮或本就自包含时可跳过改写; 也可原始 query + 改写 query 都检取并集

而排查一个"追问召不回"的 case 时,我固定先把"实际检索用的查询"打出来看:

# 排查清单:多轮追问召回差, 先看真正拿去检索的查询是什么
print("实际检索查询:", actual_search_query)
# 1. 它脱离对话历史, 你自己读得懂在问啥吗? 读不懂 -> 就是没改写
# 2. 里面有没有"它/这个/那个"等没消解的指代? 有 -> 改写漏了
# 3. 有没有省略了主语? 有 -> 补全
# 4. 把它手动补成完整问题再检一次, 若召回立刻变好 -> 实锤缺改写
# -> 在检索前加 query rewriting, 把追问上下文化成自包含查询

这套习惯的精髓,是"多轮检索前先问拿去检索的查询自包含吗、带指代省略就先 LLM 改写成独立完整查询、再检索、排查先打印实际检索查询"它让我从"用户问啥就拿啥检索",变成了"先把追问上下文化成自包含查询再检索"——核心始终是:向量检索的本质是把当前这条查询文本通过 embedding 映射成向量再去和文档向量比相似度、它能匹配到什么完全取决于这条查询文本自身承载了多少语义、它不会也无从知道这条查询之前的对话历史(检索只看你实际传给它的那一句文本而非用户在整个对话语境里真正想问的意思);在单轮提问时这两者重合(用户一句话把意思说全了),但在多轮对话里人说话天然高度依赖上下文——用指代(它/这个/那上面说的)、省略已说过的主语,因为对方记得前面说了啥,所以用户的追问如那它的有效期呢的完整语义是分散的、一部分在这句话里(有效期)、一大部分(它指代的实体、在问退款这个主题)留在了对话历史里,而你若原封不动把这句带指代和省略的残缺追问拿去 embedding 检索、检索只能从这残缺的几个字提取语义、它指谁不知道又缺主语、向量根本对不上任何具体文档于是召回空白或牛头不对马嘴(而直接把对话历史喂给 LLM 它却懂、是因为 LLM 拿到了历史能自己做指代消解、坏的只是检索这一步因为只给了它孤零零半句追问);正解是在向量检索之前先做一步查询改写 query rewriting 或上下文化——用 LLM 结合对话历史把带指代带省略的追问重写成一句脱离上下文也能独立理解的语义自包含的检索查询(把它消解成具体实体、把省略的主语补全),再拿这句自包含查询去检索,这样检索拿到的是语义完整的话自然召回准确,简单场景也可把最近几轮对话直接拼进检索查询、或原始追问与改写查询都检一遍取并集;更一般地一个信息的完整含义等于它自身携带的加它所处上下文隐含的两部分、在原始语境里这两部分天然合在一起所以它看起来是完整的、可一旦你把它从原语境抽出来交给一个访问不到那个上下文的环节(检索、翻译、排查、缓存、异步任务)那依赖上下文的那一半含义就丢失了接收环节只能拿残缺的另一半工作,所以凡是要把一个东西跨越语境边界传给新环节都要先问它的完整含义是不是依赖了当前上下文、如果是就要先把那部分上下文显式融进去让它变成自包含脱离原语境也能被正确理解的。

我立下的几条规矩

这场"追问就召回空白"的事故,换来了我做多轮 RAG 时,刻进骨子里的几条铁律:

  1. 向量检索只认你传的那句查询文本,读不到对话历史。
  2. 多轮追问带指代/省略、语义残缺,直接检索必召不回。
  3. 检索前先做查询改写,把追问改成自包含的完整查询。
  4. 用 LLM 结合历史做指代消解 + 省略补全,产出独立查询。
  5. 管线顺序:rewrite → retrieve → generate,别跳改写。
  6. 排查召回差先打印"实际检索用的查询",看它自包含吗。
  7. 任何跨语境传递的东西,先把依赖的上下文融进去自包含化。

附:一段可直接照抄的多轮 RAG 查询改写骨架

最后留一段我自己做多轮 RAG 查询改写时照着用的骨架:

REWRITE_PROMPT = """你是检索查询改写助手。根据对话历史, 把用户最新的提问
改写成一个【不依赖上下文、自己就能独立理解】的完整检索查询:
- 把"它/这个/那个"等指代消解成具体的实体名
- 补全被省略的主语/对象
- 若最新提问本身已自包含, 原样返回
只输出改写后的查询, 不要解释。

对话历史:
{history}
用户最新提问: {q}
改写后的查询:"""

def rewrite_query(history, q):
    out = llm(REWRITE_PROMPT.format(history=format_history(history), q=q))
    return out.strip()

def multi_turn_rag(history, q):
    standalone = rewrite_query(history, q)          # 1) 上下文化
    docs = vector_search(standalone, top_k=5)        # 2) 用自包含查询检索
    # 3) 生成时把历史 + 召回内容 + 原始提问一起给 LLM
    return llm(answer_prompt(history, docs, q))

# 自测: 多轮场景断言改写后的查询能独立看懂、不含未消解指代
assert "它" not in rewrite_query(hist, "那它的有效期呢?")   # 期望已消解成实体

这段骨架的核心就一句:多轮 RAG 一定是 rewrite(结合历史把追问改成自包含)→ retrieve(用自包含查询检索)→ generate(带历史和召回生成);改写这一步专治"它/这个/省略主语"。把"原始追问直接检索"换成"先改写成自包含查询",那一片"追问就召回空白"就变成"追问也召回准确"了。

写在最后

回头看,这场由"拿残缺追问直接检索"引发的"多轮召回空白"事故,真正教给我的,远不止"检索前加查询改写"这一个技巧。它让我对"一句话、一个信息的'完整含义',往往不只来自它自身,还深深依赖它所处的那个上下文;而当我们把它搬到一个没有那个上下文的地方时,我们以为搬走了完整的它,其实只搬走了它的一半",有了一次刻骨的体会。我栽跟头,是因为我把"用户在对话里说的那句话",当成了一个自己就把意思讲全了的、完整的查询;我没意识到,人在对话里说话,是站在"对方记得前文"这个共享上下文之上的——正因为有这个共享上下文兜底,人才能放心地用""、放心地省略主语,把一句话说得简短而依赖语境;这句话在对话现场是完整的(因为听者补得上),可我把它从对话现场拽出来、扔给一个压根不在场、读不到任何历史的检索系统时,那个兜底的共享上下文没跟过去,于是这句话就露出了它残缺的本相——一个没有指代对象的""、一个没有主语的问句。这让我领悟到一个关于"含义与上下文"的深刻认知:信息的含义,从来不是装在信息本身里的;它总是一部分在文本之内、一部分在语境之中——语境(对话历史、当前环境、共享背景)像一个沉默的合作者,默默补全着文本没说全的那部分意思;在原生的语境里,这种"合作"天衣无缝,以至于我们意识不到有多少含义其实是语境给的、误以为含义全在文本里;可一旦我们要把这个信息跨越语境的边界——传给另一个系统、另一个时空、另一个不共享那段历史的接收者——那个沉默的合作者就留在了原地、没有同行,信息于是骤然失去了一半的含义,而接收方只能对着残缺的文本干瞪眼;所以,真正可靠的信息传递,是在跨越语境边界之前,主动把"语境隐含的那部分含义"显式地写进信息本身,让它成为一个不依赖任何外部语境、自己就能讲清楚自己自包含体——这样它走到哪里,完整的含义都跟到哪里这给了我一种面对"一切'把某个信息传到别处去'之事"时的警觉:每当我要把一个信息(查询、消息、日志、任务、引用)传给一个不共享我当前上下文的环节,我都会问"它脱离我现在的语境,自己还讲得清楚吗?有没有'它/这个/昨天/这里'这种靠语境才能解的东西?我得先把那部分上下文补进去,让它自包含"——跨语境传递前先自包含化、把依赖的上下文显式融进去;"含义一半在语境里、跨语境前先把它补全成自包含",是做好多轮 RAG、也是一切可靠信息传递的关键认清检索只认查询自身语义、多轮追问依赖上下文、检索前要查询改写——这,是我用一次"用户一追问就召回空白"的事故,换来的、关于 AI、也关于含义与上下文之关系的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次做多轮对话检索时,先在检索前插一步"把追问改写成自包含查询",而不是把用户那句带""的话原样丢给向量库,那我对着那一片"追问就召回空白"的检索结果挠的那阵头,就值了。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

我的服务镜像一路膨胀到三四个 GB 平时不觉得、直到一次大促要紧急扩容,新 Pod 卡在拉镜像上好几分钟才起得来扩容根本来不及救火,还把节点磁盘塞得告警,排查很久才搞懂我把一堆编译工具构建缓存整套基础系统全打进了最终镜像、交付物的体积本身就是一笔我一直没正眼看的成本的深度复盘

2026-6-3 17:38:34

技术教程

我给服务做了配置热更新不重启就能改限流阈值这些参数、本以为很丝滑,可有一次我同时改了限流的阈值和时间窗口两个相关参数推下去、那一瞬间线上限流就乱套了有请求按新阈值配旧窗口的奇怪组合被误杀,排查很久才搞懂我的热更新是一个字段一个字段改的并发请求正好读到了一半新一半旧的中间态的深度复盘

2026-6-3 17:51:31

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索