☰
LangChain对话检索链:RAG多轮问答的查询改写与检索实践
2026/10/26 14:25:25 网站建设 项目流程

LangChain 里的 Conversation Retrieval Chain(对话检索链),是我带人做 RAG 项目时最先要求跑通的一条链。原因很实在:RAG 的 demo 做完之后,几乎每个人都会经历同一个瞬间——第一轮问答答得挺漂亮,你顺手追问一句“那它的缺点是什么”,模型回你的内容跟刚才那段文档半点关系都没有,甚至开始凭空编。我头一回碰到这情况时下意识怪模型,换了个参数更大的,结果一个字没变好。后来把中间过程打印出来才看明白,问题出在检索端:第二轮送进向量库的那个查询字符串,压根就不带主语。

这篇就围绕这条链把话讲透。它解决的到底是什么问题、内部三段是怎么接起来的、一个能跑起来的最小实现长什么样、哪些参数改一下行为就完全变样。适合已经跑通过最基础的单轮 RAG、正准备往多轮对话方向走的人;完全没接触过向量检索的读者也能看,我会把必要的基础概念顺手带出来,但不会从零讲向量是什么。

1. 单轮检索链跑通之后,第二轮为什么立刻答非所问

1.1 只有“当前问题”的检索器有多瞎

先把最朴素的那条链摆在桌面上。你用一段文档切好片、算好向量、塞进向量库,然后写一句retriever.get_relevant_documents(query),拿回若干片段拼进 prompt 交给模型。这条路径在单轮问答里表现很好,因为查询字符串本身信息完整,向量落点明确。

麻烦出在多轮。举个我自己项目里的真实场景(细节做了脱敏处理):文档里是两款存储方案的横向对比,A 方案写吞吐、B 方案写成本。用户第一轮问“A 方案的写入吞吐是多少”,检索命中准确,回答漂亮。紧接着第二轮追问“那它的扩容方式呢”。此时retriever收到的字符串就是“那它的扩容方式呢”这八个字。

这八个字里,“它”是一个彻底的语义空洞。向量化之后,这句话落在“扩容”“方式”这两个概念的附近,跟 A 方案还是 B 方案没有任何关联。于是召回结果可能全是 B 方案的扩容章节,也可能是某段完全无关的运维手册。更糟的是模型拿到这批错误材料后不会说“我找不到”,它会顺着材料往下编,语气还特别笃定。

提示:排查多轮答歪的第一件事,永远是把最终送进retriever的那个查询字符串打出来看,而不是盯着模型的回答反复调 prompt。九成的问题在这一步就暴露了。

1.2 Conversation Retrieval Chain 干的其实是“翻译”这一件事

这条链的核心思想简单得有点出乎意料:在真正检索之前,先让一个模型把“历史对话 + 当前问题”揉成一句独立可检索的问题(standalone question),再用这句改写后的问句去查向量库。

上面那个例子,改写环节大概会输出“A 方案的扩容方式是什么”。有了明确主语,向量落点立刻收敛,召回质量跟单轮问答拉平。

关键在于理解这一步的性质——它是翻译,不是回答。模型不需要知道 A 方案的扩容方式到底是什么,它只需要把“它”这个代词替换成前文出现过的主体。这个任务对模型能力的要求很低,用一个小尺寸的模型完全够用,延迟和成本都能压下来。很多人在这一步直接复用主回答模型,白白多花一倍推理开销,属于没必要的浪费。

注意:改写环节只输出一句话,不要让它输出解释、前缀、引号。默认的改写提示词里已经约束了格式,一旦你自己改 prompt 时松了口子,模型很容易回你“根据上文,用户想问的是……”这种带包装的句子,这句话再拿去检索,向量质量会明显下降。

1.3 这条链在整体架构里占据的位置

从 LangChain 的类结构看,ConversationalRetrievalChain继承自BaseConversationalRetrievalChain,内部挂着两个关键子组件:

组件类型作用
question_generatorLLMChain把历史 + 当前问题改写成独立问句
retrieverBaseRetriever拿改写后的问句去召回文档片段
combine_docs_chainBaseCombineDocumentsChain把召回片段和问句拼成最终 prompt 生成回答

输入字段是question和chat_history,输出字段是answer,打开开关后还会额外返回source_documents和_get_generated_question对应的改写结果。它的历史记录格式是一个二元组列表,每一项是(human_msg, ai_msg),这个格式跟后来基于消息对象的写法有区别,后面会专门说。

值得提一句的是,这条链属于 LangChain 早期 API,在新版本里官方更推荐用create_history_aware_retriever配合create_retrieval_chain来组装同样的能力。两代写法在思路上完全一致,区别在于新写法把“改写”和“检索”拆成了两个可独立测试的组件,调试起来更顺手。老链胜在开箱即用、参数少,适合先跑通再重构。

2. 把链路拆成三段:改写、检索、拼接各自在管什么

2.1 改写段:chat_history 为空时会直接短路

这里有个绝大多数人第一次调试都会撞上的细节。ConversationalRetrievalChain内部的取文档逻辑大致是这样的:先看历史记录字符串长度,如果为空,直接把原始question当作检索查询,根本不调用改写模型。

也就是说,你在测第一轮问答时,无论怎么改condense_question_prompt,都看不到任何效果,因为那条分支压根没走到。我见过有人为了验证改写 prompt 写得好不好,反复用单轮对话去测,测了半小时以为 prompt 失效,其实是短路了。

正确的验证方式是从第二轮开始看,或者手工构造一个非空的chat_history传进去。把return_generated_question=True打开,链的返回结果里会多一个字段,直接告诉你改写后到底变成了什么句子,这是排查这类问题最省时间的一个开关。

2.2 检索段:别指望在 retriever 层面解决指代问题

retriever的职责边界窄得惊人:接收一个字符串,返回一个文档列表。它不了解对话,不知道前面聊过什么,也不会去猜“它”指的是谁。自定义retriever时最容易犯的错,就是试图在get_relevant_documents里塞进去一些上下文感知的逻辑,最后把这一层搞得又复杂又难测。

我更推荐的做法是保持retriever纯粹,所有跟上下文相关的处理都前置到改写段。检索器只负责一件事:给定查询,稳定召回。这样每一层都能单独验证,出了问题定位也快。

search_kwargs里最常调的是k。默认值通常是 4,对于段落级切片、文档总量不大的场景够用;切片较碎(比如按 300 字一段)时提到 6 到 8 能明显提升召回覆盖率,代价是后面拼接段的 token 压力变大。score_threshold在部分向量库后端支持,设了之后相似度低于阈值的片段会被直接丢掉,好处是能过滤噪声,坏处是阈值设高了会出现“一条都召不回来”的情况,这个后面单独讲。

2.3 拼接段:stuff 模式好用,但它的代价是 token

默认的chain_type是stuff,行为非常直白:把所有召回片段的文本用换行拼成一整块,塞进 prompt 的上下文位置。优点是模型一次能看到全部材料,回答连贯;缺点是 token 随片段数量线性增长,k一调大就容易撑爆上下文窗口。

LangChain 给了另外几种拼接方式,实际选择时可以参考下表:

拼接方式调用次数适合场景主要代价
stuff1 次片段少、答案集中在一两段里上下文长度压力大
map_reduceN+1 次文档量大、需要跨文档汇总慢,且汇总时容易丢细节
refineN 次需要逐段迭代细化答案慢,且后段会覆盖前段结论
map_rerankN+1 次只要一个精确答案、允许丢弃材料对模型打分能力依赖强

我自己的默认选择几乎永远是stuff,理由很实际:绝大多数业务场景下k控制在 4 到 6、切片控制在 500 字左右,总 token 完全可控,而stuff的回答质量在几种方式里最稳。只有在文档量确实巨大、且问题明确需要“通读全文再总结”时,我才会考虑map_reduce。

3. 从建库到第二轮问答:一个能跑起来的最小实现

3.1 依赖与目录约定

最小依赖就是主包加向量库和文本切分这两块,嵌入和对话模型按你手头在用的后端接。目录我习惯分三块:docs/放原始文档,db/放持久化的向量库,app.py放主逻辑。分开的好处是重建索引时不用动代码,删掉db/重跑就行。

pip install langchain langchain-community chromadb

版本上要留个心眼,ConversationalRetrievalChain在不同小版本里参数名有过调整,memory和chat_history的写法也变过。动手前先看一眼当前安装版本里这个类的签名,比照着旧文章抄参数要省事得多。

3.2 切分参数怎么定,这一步决定了后面所有环节的天花板

切分是整条链里最容易被草率对待、又最影响效果的一步。我的经验值是这样:中文技术文档,chunk_size取 400 到 600 字,chunk_overlap取 50 到 80 字。理由有两层。

一是检索粒度。切片太大,一段里塞了三四个主题,向量是这几个主题的平均值,查询命中时召回的是“一锅粥”,模型还得自己从里面挑。切片太小,一个完整论述被切成两半,召回一半丢了另一半,回答就会缺胳膊少腿。

二是overlap的作用常常被误解。它不是为了让切片更长,而是为了防止关键句正好落在切口上。比如一个参数说明被切成“超时时间默认是”和“30 秒,超过会重试”,没有重叠的话,无论召回哪一半信息都不完整。

from langchain.text_splitter import RecursiveCharacterTextSplitter splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=60, separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""], ) chunks = splitter.split_documents(raw_docs)

separators这一项对中文很关键。默认分隔符表是按英文标点设计的,中文段落常常整段不带英文句号,结果就是按字符硬切,切口落在词中间。把中文标点补进去,切出来的片段边界会自然很多。

3.3 建库与检索器

from langchain_community.vectorstores import Chroma from langchain_community.embeddings import HuggingFaceEmbeddings embeddings = HuggingFaceEmbeddings(model_name="你的嵌入模型") vectorstore = Chroma.from_documents( documents=chunks, embedding=embeddings, persist_directory="./db", ) retriever = vectorstore.as_retriever( search_type="similarity", search_kwargs={"k": 5}, )

search_type还有mmr这个选项,它会在相似度和结果多样性之间做权衡。文档里存在大量重复表述时,mmr能避免召回五条几乎一样的内容,实测对“同一段话在多个章节重复出现”的文档效果提升明显。缺点是它比纯相似度慢一些,因为要先取一个更大的候选池再做筛选。

3.4 组装链条,手动传历史

先用最直白的方式把链跑起来,历史记录自己维护,这样每个环节发生了什么你都能看见。

from langchain.chains import ConversationalRetrievalChain qa_chain = ConversationalRetrievalChain.from_llm( llm=你的对话模型, retriever=retriever, condense_question_llm=你的小模型, return_source_documents=True, return_generated_question=True, verbose=False, ) chat_history = [] def ask(question: str): result = qa_chain({ "question": question, "chat_history": chat_history, }) chat_history.append((question, result["answer"])) print("改写后:", result.get("generated_question")) print("命中片段数:", len(result["source_documents"])) return result["answer"]

跑两轮就能看到改写环节的真实输出。第一轮generated_question是空或者等于原问题,第二轮才会出现真正的改写句。把这个值打出来,你对整条链的理解会比读十篇文档都直观。

3.5 换成 Memory 自动管历史

手动维护列表适合调试,上生产一般交给ConversationBufferMemory。这里有个必须踩准的点:memory_key的名字要和链期望的输入字段对齐,output_key要指向answer,漏了任何一个都会报键不存在的错。

from langchain.memory import ConversationBufferMemory memory = ConversationBufferMemory( memory_key="chat_history", output_key="answer", return_messages=False, ) qa_chain = ConversationalRetrievalChain.from_llm( llm=你的对话模型, retriever=retriever, memory=memory, return_source_documents=True, )

return_messages这个开关很容易搞混。设成False时历史是字符串二元组,老链能直接吃;设成True时历史是消息对象列表,部分版本的老链处理起来会出问题。稳妥起见,用老链就配False,用新一代的create_history_aware_retriever才配True。

4. 参数层面的三个暗坑:改写模型、token 上限、prompt 注入点

4.1 改写模型和回答模型该不该分开

分开,而且强烈建议分开。理由前面提过一半:改写任务是轻量的格式转换,不需要强推理。补上另一半:两个环节的失败模式完全不同。改写失败表现为“问题被改得面目全非”,回答失败表现为“材料对但话说歪了”。用同一个模型时,两类问题的日志混在一起,排查起来非常费劲。

分开之后还有个隐性收益:改写环节可以用更低的温度。改写要的是确定性,同一段历史加同一个问题,理想情况下每次应该输出同一句改写结果。温度调到 0 附近,行为会稳定很多,缓存也才有意义。

4.2 max_tokens_limit 触发后会静默丢文档

max_tokens_limit这个参数名字容易让人以为是“截断文本长度”,实际行为是按 token 预算逐条丢弃文档片段,直到总量落进限制内。它不报错、不警告,只是悄悄少给你几条材料。

后果是:你明明设了k=8,实际参与生成的可能只有 3 条,而你在日志里看到的仍是 8 条召回结果。回答质量莫名其妙地下降,却找不到原因。

排查办法很简单,在拿回结果后打个日志对比召回数量和实际使用数量:

result = qa_chain({"question": q, "chat_history": chat_history}) print(len(result["source_documents"])) # 实际参与生成的条数

如果这个数明显小于你设的k,要么调小k,要么把max_tokens_limit放开。我个人倾向于不放这个参数,而是通过控制切片大小和k从源头把预算管住,行为更可预测。

4.3 自定义 prompt 的三个注入位置

这条链的 prompt 不是一整块,分散在三个地方,改错位置是常见事故:

注入位置参数名影响什么
改写环节condense_question_prompt历史怎么揉成独立问句
回答环节combine_docs_chain.llm_chain.prompt材料怎么拼进最终 prompt
文档拼接模板combine_docs_chain.document_variable_name材料放在哪个变量位

最常被改的是第二个。想让模型“只依据材料回答、找不到就说不知道”,必须在这里下手,把约束写进system或指令段。放在改写 prompt 里是无效的,因为改写环节根本不生成答案。

第三个参数看着冷门,实际很容易踩。默认变量名是context,如果你自定义 prompt 里写的是{docs},而没同步改这个参数,运行时会直接报变量缺失。这类报错信息通常不太直观,第一次遇到要愣一会儿。

5. 实测中反复出现的四个问题,以及我的处理方式

5.1 轮次一多,改写出来的问题越来越长

这是我最常遇到的问题。聊到七八轮之后,历史记录越滚越长,改写模型倾向于把前面所有内容都揉进那句“独立问句”里,输出变成一大段复述。这句超长查询拿去向量化,语义被稀释,召回质量反而下降。

根子在于历史没有裁剪。我的处理方式是只保留最近 N 轮(N 取 3 到 5),再往前的不进改写上下文。实现上不用换组件,自己在传入chat_history前切一下就行:

def trim(history, keep_last=4): return history[-keep_last:]

另一个可选方向是换更聪明的历史处理策略,比如用ConversationSummaryMemory把早期对话压成摘要。但这会引入额外的一次模型调用,且摘要本身有信息损失,我一般只在确实需要长程记忆的场景才用。

5.2 检索为空时的兜底,比想象中重要

score_threshold设了之后,或者查询本身完全不在文档覆盖范围内时,召回结果可能是空列表。此时stuff链拿不到任何材料,模型面对的是一个空的上下文位置,行为很不确定:有时候说“资料中没有”,有时候直接凭训练知识开答,而且看不出来区别。

我的做法是在调用链之前加一层前置判断,把空检索转成明确的拒答话术,不给模型自行发挥的空间。这一步看似降低了“智能感”,实际大幅提升了可信度——用户宁愿听到一句“这块资料里没有”,也不愿意听到一段编得很像的答案。

同时补一句:空检索也可能是改写环节出了岔子。把generated_question打出来,如果发现改写句离谱,问题在改写不在检索,别急着去调k和阈值。

5.3 中文长文档的切片边界与相似度偏差

中文场景有两个坑比较集中。一是标点分隔符,前面已经说过,补充完整即可。二是嵌入模型的语言匹配度,用英文语料为主的嵌入模型去算中文相似度,结果会明显偏平——分数集中在 0.6 到 0.8 之间,区分度差,score_threshold基本没法设。

判断方法很直接:拿两句语义完全不同、但都属于同一领域的中文,算一下相似度。如果分数高得离谱(比如 0.85 以上),说明这个嵌入模型对中文的区分能力不足,换一个在中文上表现更好的模型,整条链的效果会有一个台阶式的提升。这个判断我一般放在项目最开头做,比后面调任何参数都管用。

5.4 历史记录无限增长带来的内存与成本问题

ConversationBufferMemory的默认行为是把所有历史都存着。单用户调试时看不出问题,多用户并发跑一段时间后,内存占用会稳步上升,而且每次调用传给模型的 token 也跟着涨。

处理上分两步:会话级做轮次裁剪,会话结束后主动清理。如果要做多用户隔离,每个会话 ID 对应一个独立的 memory 实例,别图省事共用全局的。真要做持久化,把历史落到本地数据库里,按会话 ID 存取,重启服务时不至于全丢。

我个人的习惯是给每个会话设一个硬上限,超过就滚动淘汰最老的一轮。相比摘要压缩,这个策略实现简单、行为可预测,代价只是损失远期记忆,对大多数问答型场景是可以接受的取舍。

最后分享一个我反复验证过的小习惯:每次接入一个新文档集,先只跑两轮问答,把generated_question、召回片段数、最终回答三者一起打出来看一遍。这三个值只要有一个不对劲,后面所有调参都是白费力气。我踩过最亏的一次坑,是在召回环节折腾了大半天,回头一看发现是改写输出带了个多余的“问题是:”前缀,把向量查询污染了。这类问题不靠人工盯,是发现不了的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询