在构建企业知识库问答系统时,开发者常常遇到一个矛盾:文档总量远超大模型上下文窗口,而用户又希望连续多轮对话。LangChain 提供的 Map-Reduce 链能将长文本分块映射再归约,但它默认无状态。要让它同时具备外部检索能力和会话记忆,需要把检索器、记忆模块与链内部提示词显式编织在一起。下面从工程结构、检索整合与记忆挂接三个层面详细说明。

Map-Reduce 链的基础结构与工作原理
Map-Reduce 链的核心思想是分而治之。假设我们有一份三百页的产品手册,直接丢给模型会触发截断。LangChain 的 MapReduceDocumentsChain 会先将文档按 chunk_size 切分为若干片段,然后对每个片段调用 map 提示生成局部摘要,最后把所有摘要送入 combine 提示产生全局答案。这种结构天然适合批处理,但要注意 map 阶段彼此独立,模型看不到其他块的内容。
在代码层面,我们需要准备两个提示模板:一个用于映射,一个用于合并。映射提示通常要求“根据以下文段回答问题,若无关则回不知道”;合并提示则要求“基于若干摘要回答同一问题”。由于 map 是并行或串行执行的,如果问题本身依赖跨段落推理,就必须在 combine 阶段补上必要的前后文。这也是为什么单纯 Map-Reduce 仍需要检索环节先做筛选,而不是对所有文档无差别摘要。
下面给出一个最小可运行的骨架,展示如何定义链并传入文档列表。注意这里暂未接入记忆,仅体现结构。
from langchain.chains import MapReduceDocumentsChain, ReduceDocumentsChain
from langchain.chains.llm import LLMChain
from langchain.prompts import PromptTemplate
from langchain.llms import OpenAI
llm = OpenAI(temperature=0)
map_prompt = PromptTemplate.from_template(
"文段:{context}n问题:{question}n请仅根据该文段作答:"
)
map_chain = LLMChain(llm=llm, prompt=map_prompt)
combine_prompt = PromptTemplate.from_template(
"多个摘要:{context}n问题:{question}n综合回答:"
)
reduce_chain = ReduceDocumentsChain(
combine_documents_chain=LLMChain(llm=llm, prompt=combine_prompt),
collapse_documents_chain=LLMChain(llm=llm, prompt=combine_prompt),
token_max=3000,
)
chain = MapReduceDocumentsChain(
llm_chain=map_chain,
reduce_documents_chain=reduce_chain,
document_variable_name="context",
return_intermediate_steps=False,
)
将向量检索嵌入 Map-Reduce 前置流程
带检索的问答并不是把检索器塞进 Map-Reduce 内部,而是在链外先完成召回。具体做法是使用 VectorStoreRetriever 根据用户问题取出 top-k 相关片段,再把这些片段交给 Map-Reduce 链处理。这样做能把三百页文档缩减到五段最相关文本,既省 token 又提升准确率。检索器返回的 Document 对象带有 page_content 与 metadata,可直接复用。
一个常见误区是每次用户提问都重新嵌入全部文档。正确做法是启动时建一次向量库,之后只做相似度搜索。若文档频繁变动,可用增量写入。下面示例展示如何结合 Chroma 与检索结果驱动 Map-Reduce。注意检索语句中的 question 变量来自前端输入,要防注入可加白名单校验。
检索与 Map-Reduce 的组合让系统从“读全部”变成“读相关”。但在多轮对话里,用户第二问可能省略主语,例如首轮问“退款政策”,次轮问“那跨境订单呢”。若只拿次轮短句去检索,向量库会丢失“退款”语境。这就引出了下一节记忆模块的必要性。
from langchain.vectorstores import Chroma
from langchain.embeddings import OpenAIEmbeddings
embeddings = OpenAIEmbeddings()
vectordb = Chroma(collection_name="manual", embedding_function=embeddings)
retriever = vectordb.as_retriever(search_kwargs={"k": 4})
def retrieve_then_mapreduce(question):
docs = retriever.get_relevant_documents(question)
return chain.run(documents=docs, question=question)
answer = retrieve_then_mapreduce("如何申请退款")
print(answer)
为 Map-Reduce 链挂载多轮对话记忆
LangChain 的 ConversationBufferMemory 可以保存历史消息。但 Map-Reduce 链本身不消费记忆变量,我们需要手动把历史拼进问题或提示。推荐方案是维护一个记忆缓冲区,在每次调用前用模板将历史问答与当前问题融合成“增强问题”,再送去检索与映射。例如把历史最后三轮转成文本前缀,让检索器理解指代。
另一种更严谨的做法是定义带 chat_history 变量的 map 提示,在每段映射时都注入精简历史。不过这样会放大 token 消耗,仅建议在跨块推理强的场景使用。通常把历史放在检索查询增强与 combine 提示就够了。下面代码演示记忆类与链调用的衔接,其中 memory.load_memory_variables({}) 取出过往对话。
需要强调的是,记忆内容不能代替检索。记忆负责语境连贯,检索负责事实来源。二者通过“增强查询”桥梁连接:先用记忆补全当前问题,再用补全句检索,最后 Map-Reduce 汇总。如此用户问“它支持微信支付吗”时,系统已知“它”指前文的某产品,从而召回准确片段并给出答案。
from langchain.memory import ConversationBufferMemory
memory = ConversationBufferMemory(memory_key="chat_history", return_messages=True)
def chat(question):
history = memory.load_memory_variables({}).get("chat_history", [])
enhanced_q = question
if history:
enhanced_q = history[-1].content + " 之后问:" + question
docs = retriever.get_relevant_documents(enhanced_q)
result = chain.run(documents=docs, question=enhanced_q)
memory.save_context({"input": question}, {"output": result})
return result
print(chat("退款要几天"))
print(chat("跨境订单也适用吗"))
工程落地中的参数与避坑要点
在真实部署时,token_max 参数决定合并阶段单次传入的摘要长度。若设得过大,combine 提示会超出模型窗口;过小则触发多次 collapse,延迟升高。一般取模型上下文的六成。另外检索的 k 值不宜超过八,否则 map 阶段并行摘要成本陡增,且噪声片段降低信噪比。
另一个坑是记忆无限增长。ConversationBufferMemory 不截断,长会话会撑爆上下文。可改用 ConversationSummaryMemory 自动压缩,或限定只保留最近五轮。同时,Map-Reduce 的 map 提示要避免输出长篇,应加一句“用两句话以内回答”,防止中间摘要膨胀。最后,所有用户输入进提示前需做转义,避免恶意构造的文段闭合模板标签。
整体看,带记忆与检索的 Map-Reduce 链是一条“检索召回相关块、记忆补全指代、分块摘要再归约”的流水线。它平衡了长度限制、事实性与对话连贯,是中等规模知识库问答的务实选择。当文档跨过十万页,再考虑引入重排序与层级 Reduce 来替代扁平合并。
LangChainMap-Reduceretrieval_chain修改时间:2026-08-14 03:45:34