22.1 rag重点方法笔记

在 LangChain 表达式语言(LCEL)中,RunnablePassthrough() 的核心作用极其简单直接:"原封不动地传递输入,不做任何修改"

但在你提供的这段代码中,它的妙用在于配合 RunnableParallel(即字典 {})实现数据的"分流"与"合并"

为了让你彻底看懂,我把这段代码中的数据流拆解成三步:

1. 它在代码中具体做了什么?

在你的 chain 最开头,定义了一个字典(并行执行器):

python 复制代码
{
    "input": RunnablePassthrough(),  # 支路 A:直通
    "context": RunnableLambda(format_for_retriever) | retriever | format_document  # 支路 B:检索
}

当用户调用 chain.invoke({"input": "针织毛衣如何保养?", "history": [...]}) 时:

  • 支路 B(检索) :拿着用户的原始问题去向量库检索相关资料,加工成 context
  • 支路 A(直通)RunnablePassthrough() 什么都不干 ,直接把用户传进来的整个原始字典 {"input": "针织毛衣...", "history": [...]} 赋值给了 "input" 这个键。

此时,经过这一步处理后,数据变成了:

python 复制代码
{
    "input": {"input": "针织毛衣如何保养?", "history": [...]},  # 原封不动的原始输入
    "context": "文档片段:针织毛衣应冷水手洗...\n文档元数据:..."    # 检索到的资料
}

2. 为什么非要加这一句?(面试高频追问)

如果没有 RunnablePassthrough(),写成 "input": "随便写个字符串",那用户的原始输入和对话历史就丢了。

之所以这么设计,是因为后续的 format_for_prompt_template 函数需要从 value["input"]["history"] 中提取对话记录

  • 没有它value["input"] 只是一个字符串(比如 "用户问题"),你没法从字符串里取 history,程序会报错。
  • 有了它:它像一条"旁路",把最原始的请求完整地保留了下来,供后面的 Prompt 模板使用。

3. 直观类比

想象一个快递分拣中心(chain):

  • 你送来一个包裹(原始输入),里面装着**"问题""聊天记录"**。
  • 支路 B(检索) :拆开包裹,看一眼"问题",跑去仓库(向量库)找相关的资料(context)。
  • 支路 A(RunnablePassthrough不拆封,直接把整个原包裹贴上一个"原始数据"的标签,送到下一站。

最后,下一站的组装员(Prompt模板)手里有两样东西:刚找来的资料(context)没拆封的原包裹(input),他打开原包裹拿出"问题"和"聊天记录",三者一起组装成最终发给大模型的提示词。


补充:一个易混淆点

LangChain 中还有另一个常用方法叫 RunnablePassthrough.assign(),这里要区分一下:

  • RunnablePassthrough()(你代码中的) :保持输入原样输出。
  • RunnablePassthrough.assign(...) :保持输入不变,额外追加新的键值对(常用于在不丢失原数据的情况下,添加计算后的新字段)。

在你的代码中,不需要追加,所以直接用基础版本作为字典中 "input" 键的值,完美实现了"保留原始请求上下文"的需求。


总结一句话 :在你的 RAG 链中,RunnablePassthrough() 充当了**"数据保留中转站",确保用户的完整请求(包括对话历史)在并行检索时不会被消耗掉,能安全地流入最后的 Prompt 组装环节。
这段代码是 LangChain 表达式语言(LCEL)的
核心执行链**。它利用管道操作符 |,将数据像流水线一样,从左到右经过层层加工,最终生成答案。

为了帮你彻底看懂,我把它拆解为 5 个核心阶段,并追踪一份"虚拟数据"在其中的流转过程:


阶段零:并行检索(字典内部)

python 复制代码
"context": RunnableLambda(format_for_retriever) | retriever | format_document

含义 :这是链条的起点 ,发生在前面的 {}(并行字典)内部。

  • RunnableLambda(format_for_retriever):将用户输入 {"input": "问题", "history": [...]} 转化为纯字符串 "问题"(因为检索器只接受字符串)。
  • | retriever:拿着纯文本问题去向量库搜索,返回一堆 Document 对象。
  • | format_document:将这堆 Document 对象格式化为 Prompt 能看懂的大段文本字符串(包含内容和元数据)。

此时产出 :并行字典 {"input": 原始请求, "context": "格式化的参考文档"} 进入下一阶段。


阶段一:数据整形(RunnableLambda(format_for_prompt_template)

python 复制代码
} | RunnableLambda(format_for_prompt_template)

含义 :将上一步产生的字典,重新构造成 Prompt 模板所需要的精确结构

看你的 format_for_prompt_template 函数:

python 复制代码
def format_for_prompt_template(value):
    # value = {"input": {"input": "问题", "history": [...]}, "context": "文档内容"}
    new_value["input"] = value["input"]["input"]   # 提取出纯问题字符串
    new_value["context"] = value["context"]        # 直接拿过来
    new_value["history"] = value["input"]["history"]# 提取对话历史
    return new_value

为什么要有这一步? 因为你的 Prompt 模板变量名是 {input}{context}{history},这一步相当于把杂乱的数据"拧"成模板能直接匹配的钥匙。

此时产出{"input": "针织毛衣如何保养?", "context": "文档内容...", "history": [("user","你好"), ...]}


阶段二:填充模板(self.prompt_template

python 复制代码
| self.prompt_template

含义 :将上一步的三个键值对,填入你定义好的 ChatPromptTemplate 中。

最终拼接成发给大模型的完整字符串(包含 System 提示词、History 对话记录、User 最新提问)。

此时产出 :一个完整的 PromptValue 对象(包含所有消息结构)。


阶段三:调试钩子(print_prompt

python 复制代码
| print_prompt 

含义 :这是你自定义的调试函数(def print_prompt(prompt): print(...); return prompt)。

只拦截查看,不修改数据。打印出的内容能帮你确认"发给大模型的提示词长什么样",在排错时极其有用。

此时产出 :原封不动的 PromptValue 对象。


阶段四:模型推理(self.chat_model

python 复制代码
| self.chat_model 

含义:调用通义千问(Qwen)的 API。

模型接收上一步拼好的完整提示词,进行推理,返回一个包含完整回答的 AIMessage 对象(包含 content 属性和一些元数据)。

此时产出AIMessage(content="针织毛衣建议冷水手洗,平铺晾干...")


阶段五:提取字符串(StrOutputParser()

python 复制代码
| StrOutputParser()

含义:这是最后一关。模型返回的是对象(有各种属性),但我们只需要文本内容。

  • 输入AIMessage(content="...")
  • 输出 :纯字符串 "针织毛衣建议冷水手洗,平铺晾干..."

最终返回给前端/用户:干干净净的纯文本答案。


总结(一句话概括)

这条链的逻辑是:取问题(Lambda)→ 查资料(Retriever)→ 整理资料(Lambda)→ 拼提示词(Template)→ 打印看效果 → 发给大模型(ChatModel)→ 只拿文字出来(Parser)

这就像一条自动化奶茶生产线:(放杯子 → 加茶底 → 加小料)→ 贴标签 → 递给奶茶师傅(大模型)→ 师傅做完后撕掉外卖单(Parser),只把奶茶递给顾客

如果你对其中某个环节(比如 RunnableLambda 为什么非要套一层,或者 StrOutputParser 还有别的用法)还想深挖,我可以随时为你细化。

这段代码是 RAG 链路中非常关键的**"数据格式化"**环节。它的核心作用是把向量数据库查出来的"原始答案碎片",加工成大模型能看懂、能引用的"标准参考资料"。


1. 代码逐行解读

python 复制代码
def format_document(docs: list[Document]):
    # 1. 防御性检查:如果检索器没找到任何相关资料
    if not docs:
        return "无相关参考资料"  # 返回一个占位符,告知大模型"我没查到资料"

    formatted_str = ""  # 初始化一个空字符串,准备拼接内容

    # 2. 遍历检索到的每一个文档片段
    for doc in docs:
        # 3. 按固定格式拼接:把文本内容和来源信息都加进去
        formatted_str += f"文档片段:{doc.page_content}\n文档元数据:{doc.metadata}\n\n"

    return formatted_str  # 返回一个完整的大段文本

这段代码的逻辑很直白 :把一堆文档对象变成一段结构清晰的文本。特别注意 if not docs 这一句,如果不加这句,当知识库没相关内容时,大模型收到的 {context} 就是空的,极大概率会"编造"答案;加上这句话,模型就会知道"没有参考资料",进而触发系统提示词中"不知道就说不知道"的规则。


2. 代码里的 Document 到底是什么?

DocumentLangChain 框架中定义的一个基础数据类,你可以把它理解为 LangChain 世界里流通的"通用货币"或"标准快递箱"。

只要数据(无论是 PDF、Word、网页还是数据库记录)被喂进 LangChain 体系,最终都会被封装成 Document 对象,方便后续的检索器(Retriever)、分割器(Splitter)统一处理。它的核心结构非常简单:

属性 类型 含义(必记)
page_content str 真正的文本内容。比如你切分出来的那一段文字、那一句话。
metadata dict 附带的元数据(标签) 。是个字典,用来记录这段文本的"出身",比如:{"source": "针织手册.pdf", "page": 15, "author": "张三"}。这在做答案溯源时极其有用------你可以知道模型引用的内容来自哪份文件。
id (可选) str 文档的唯一标识符(在某些向量库里会用到)。

3. 结合你的 RAG 链路看具体数据流

在你之前的代码中,retriever(检索器)执行 similarity_search 后,返回的数据类型正是 List[Document] (文档列表)。而你的 format_document 函数就是在处理这个列表。

假设用户问"针织毛衣如何保养?",向量库召回了 2 个 Document,它们在内存中大致长这样:

原始数据(List[Document]):

  • 文档1page_content="针织毛衣建议使用30度以下冷水手洗...", metadata={"file": "保养指南.pdf", "page": 3}
  • 文档2page_content="清洗后应平铺晾干,避免悬挂导致变形...", metadata={"file": "保养指南.pdf", "page": 4}

经过 format_document 处理后(变成字符串):

text 复制代码
文档片段:针织毛衣建议使用30度以下冷水手洗...
文档元数据:{'file': '保养指南.pdf', 'page': 3}

文档片段:清洗后应平铺晾干,避免悬挂导致变形...
文档元数据:{'file': '保养指南.pdf', 'page': 4}

最终,这段字符串会被填入你 Prompt 模板的 {context} 位置,大模型看到的就是带有清晰来源标记的参考资料。


4. 面试官如果追问(加分点)

问:为什么要把 Document 转成字符串,而不是直接丢给大模型?

答:因为大模型(LLM)的 API 接收的是纯文本或消息列表(Messages),它不认识 Python 对象。我们必须把 Document 里的文本和元数据"提取"并"格式化"成自然语言,注入到 System Prompt 中。此外,把元数据(如页码、文件名)拼进去,能让大模型在回答时附带引用出处,极大增强答案的可信度。

问:这样拼接字符串会不会超过大模型的上下文长度(Context Window)限制?

答:这是 RAG 工程中的核心痛点。通常我们会在 retriever 检索时通过 top_k 参数(比如只取前 5 个)来控制返回的 Document 数量;或者在检索前对 Document 做进一步的长度截断。这里只是纯粹的格式化,不做截断 ,截断逻辑通常放在检索器参数或前置的文本分割器中。

这行代码是 Python 中非常经典的类型安全检查

字面意思 :判断变量 stream 是否属于 Python 内置的 字符串(str)类型 (或者是 str 的子类)。

为什么用 isinstance 而不是 type(stream) == str

因为 isinstance 支持继承关系,更符合 Python 的"鸭子类型"哲学。如果 stream 是某种自定义字符串子类,isinstance 依然返回 True,而 type 会返回 False,代码兼容性更强。


在 AI / RAG 编程中的特定场景(必看)

在之前的对话中,我们提到了 StrOutputParser 会提取纯字符串。这行代码通常出现在自定义流式输出处理器回调函数复杂的链式解析中。

结合上下文变量名 stream,它代表**"从大模型 API 返回的一个流式数据包(Chunk)"。这行代码的核心意图是兼容并包**:

由于不同大模型厂商(如通义千问、OpenAI、智谱)返回的流式数据格式不统一:

  • 有的厂商返回的是 纯字符串(如某些开源模型直接吐文本)。
  • 有的厂商返回的是 字典 / JSON 对象 (如包含 choices[0].delta.content)。

这行代码的实际作用就是做"分流处理",防止程序在拼接答案时崩溃。伪代码逻辑如下:

python 复制代码
# 模拟接收流式数据包
for chunk in response_stream:
    # 如果是纯字符串(直接就是文本),直接拼接
    if isinstance(chunk, str):
        full_answer += chunk
    # 如果是字典(例如 OpenAI 格式),就深入内部提取文本
    elif isinstance(chunk, dict):
        full_answer += chunk["choices"][0]["delta"]["content"]
    # 否则忽略或报错

如果面试官追问(加分项)

问:如果我把 if isinstance(stream, str) 改为 if type(stream) is str,有什么隐患?

:如果大模型 SDK 内部封装了自定义字符串类(例如继承自 strCustomString),type 严格比较会返回 False,导致代码将其误判为"非字符串"而跳过处理,造成输出为空。使用 isinstance 更安全,它承认"子类也是父类"。

问:既然你提到了大模型流式,那 StrOutputParser 是干嘛的,和这个判断有什么关系?

StrOutputParser 负责在非流式 场景下把完整的 AIMessage 对象转成字符串。但在流式场景 下,我们处理的是一个个碎片,无法直接套用 Parser。所以通常会自定义一个 RunnableL ambda 或生成器函数,在函数内部利用 isinstance(stream, str) 这样的判断,把各种五花八门的碎片统一"洗"成文本,再拼起来返回给前端。

别担心,isinstance() 是 Python 里最基础也最常用的内置函数之一,理解它很简单。我们抛开复杂术语,用**"查户口"**的方式来理解。


1. 它的"身份证"长什么样?

python 复制代码
isinstance(对象, 类型)
  • 功能:问你第一个参数(对象)是不是第二个参数(类型)的"家庭成员"(实例)。
  • 返回值 :是则返回 True,否则返回 False

2. 生活中的类比(秒懂)

想象你面前有一个不锈钢保温杯 (这就是对象)。

  • 你问它:"你是杯子吗?" ------ 是!(返回 True
  • 你问它:"你是苹果吗?" ------ 不是!(返回 False

用代码翻译就是:

python 复制代码
cup = "一个不锈钢保温杯"  # 这是个字符串对象

print(isinstance(cup, str))   # 问:它是字符串吗? 输出 True
print(isinstance(cup, int))   # 问:它是数字吗? 输出 False
print(isinstance(cup, list))  # 问:它是列表吗? 输出 False

3. 为什么一定要用它?(type()isinstance() 的区别)

很多新手会问:"我用 type(cup) == str 不也能判断吗?"

核心区别在于:isinstance 认"干儿子"和"家族血脉",而 type 只认"亲儿子"。

在 Python 里,我们可以继承一个类(相当于认了个干儿子)。举个例子:

python 复制代码
# 1. 定义一个父类:动物
class Animal:
    pass

# 2. 定义一个子类(继承自动物):狗
class Dog(Animal):
    pass

# 3. 创建一只狗
my_pet = Dog()

# 用 type 判断(只看眼前这张身份证)
print(type(my_pet) == Animal)   # 输出 False (因为身份证上写的是 Dog,不是 Animal)

# 用 isinstance 判断(查户口,问祖宗)
print(isinstance(my_pet, Animal)) # 输出 True (因为 Dog 的老祖宗是 Animal)

结论isinstance 更宽容、更安全。在大型项目中,为了代码的健壮性,绝大多数情况下都推荐用 isinstance


4. 回到你之前的那行代码 if isinstance(stream, str):

结合上面的知识,这行代码就是在问变量 stream

"你是不是一个字符串(str)类型的东西?"

  • 如果大模型传回来的数据包是 纯文本 (比如 "你好"),问它是不是 str,回答是 True,于是程序直接拼接。
  • 如果大模型传回来的数据包是 字典 (比如 {"text": "你好"}),问它是不是 str,回答是 False,于是程序就走 else 分支,去字典里把 "你好" 挖出来。

5. 进阶用法(面试可能追问)

isinstance 还能同时检查多种类型,只要满足其中一个就算通过:

python 复制代码
# 问:stream 是不是字符串 或者 字典?
if isinstance(stream, (str, dict)):
    print("它是这两种中的某一种!")

帮你总结一句话isinstance(变量, 类型) 就是**"老板,帮我查一下这个变量是不是属于这个类型大家族",返回值只有 。它的作用就是让程序在面对不同格式的数据时,能做出不同的应对,防止程序报错崩溃。😊
如果还想了解 type()isinstance()
面向对象继承**中的具体区别,或者在实际 API 请求中怎么用它做校验,随时可以告诉我。