第一次用 LangChain 搭建 RAG 链时,很多人都会遇到同一个疑问:向量库明明可以通过 similarity_search() 检索文档,为什么还要调用 as_retriever(),才能接入 LCEL?
更让人困惑的是,代码中一个普通的字典,为什么能让同一个用户问题同时流向 input 和 context 两个分支?
本文通过一个完整示例,解释以下几个问题:
- 为什么
VectorStore不能直接作为 LCEL 链中的检索步骤? as_retriever()做了什么?similarity_search()和as_retriever()有什么区别?- 字典为什么可以把同一个输入分发给多个分支?
RunnablePassthrough()在检索链中有什么作用?
一、示例:
python
from langchain_qwq import ChatQwen
from langchain_community.embeddings import DashScopeEmbeddings
from langchain_core.documents import Document
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_core.vectorstores import InMemoryVectorStore
def print_prompt(prompt):
"""打印发送给模型的提示词,并把提示词继续传给下一个步骤。"""
print("发送给模型的提示词:")
print(prompt.to_string())
print("=" * 40)
return prompt
# 当前官方 Qwen 集成使用 ChatQwen。
model = ChatQwen(model="qwen3-max")
prompt = ChatPromptTemplate.from_messages(
[
(
"system",
"请优先依据参考资料回答问题,回答要简洁、专业。\n\n"
"参考资料:\n{context}",
),
("human", "用户问题:{input}"),
]
)
# InMemoryVectorStore 只适合演示,进程结束后数据会丢失。
embeddings = DashScopeEmbeddings(model="text-embedding-v4")
vector_store = InMemoryVectorStore(embedding=embeddings)
# add_texts 接收原始字符串,并调用 embedding 模型生成向量后写入向量库。
# 如果数据已经经过文档加载、切分,并且需要保留 metadata,通常使用 add_documents。
vector_store.add_texts(
texts=[
"减肥就是要少吃多练。",
"减脂期间需要合理饮食,清淡少油,控制热量摄入,并保持适量运动。",
"跑步是一种常见的有氧运动。",
]
)
input_text = "怎么减肥?"
# as_retriever() 返回 VectorStoreRetriever。
# 它实现了 Runnable 接口,因此可以直接接入 LCEL。
# 指定返回两个相关的向量 作用与 similarity_search 这个方法相似 两者区别在下文会展示
# 进行向量检索,返回向量
retriever = vector_store.as_retriever(search_kwargs={"k": 2})
def format_docs(docs: list[Document]) -> str:
"""把检索到的 Document 列表整理成提示词中的上下文字符串。"""
if not docs: #如果为空说明没有检索到
return "无相关参考资料"
return "\n\n".join(
f"[资料 {index}]\n{doc.page_content}"
for index, doc in enumerate(docs, start=1)
)
chain = (
{
# 保留原始用户问题,供 Prompt 的 {input} 使用。
"input": RunnablePassthrough(),
# 检索文档并格式化,供 Prompt 的 {context} 使用。
"context": retriever | format_docs,
}
| prompt
| print_prompt
| model
| StrOutputParser()
)
result = chain.invoke(input_text)
print(result)
这条链中,各组件的输入和输出如下:
| 组件 | 输入 | 输出 |
|---|---|---|
retriever |
str 类型的用户问题 |
list[Document] |
format_docs |
list[Document] |
str 类型的上下文 |
| 字典分支 | 同一个用户问题 | {"input": ..., "context": ...} |
prompt |
包含问题和上下文的字典 | ChatPromptValue |
model |
格式化后的消息 | AIMessage |
StrOutputParser |
AIMessage |
str |
如图是对这个流程的详细解释: 
二、similarity_search() 与 as_retriever()
1. similarity_search():直接执行搜索
向量库可以直接调用 similarity_search():
python
docs = vector_store.similarity_search(
query="怎么减肥?",
k=2,
)
print(docs) # list[Document]
这个方法会立即执行搜索,并直接返回一个 list[Document]。
similarity_search() 返回的是已经计算完成的数据,而不是一个可以等待输入、再执行搜索的 Runnable 步骤。把它直接写在链外,会导致搜索在构建链时就执行,无法使用每次调用时传入的用户问题。
如果确实需要把 similarity_search() 包装成 LCEL 步骤,也可以使用 RunnableLambda:
python
from langchain_core.runnables import RunnableLambda
search = RunnableLambda(
lambda query: vector_store.similarity_search(query, k=2)
)
不过,在向量检索场景中更常用的写法是 as_retriever(),因为它提供了统一的 Retriever/Runnable 接口。

2. as_retriever():把向量库转换成检索器
python
retriever = vector_store.as_retriever(
search_kwargs={"k": 2}
)
docs = retriever.invoke("怎么减肥?")
as_retriever() 并不是删除或替代 similarity_search(),而是把向量库的搜索能力封装成 VectorStoreRetriever。检索器遵循标准 Runnable 接口,可以使用 invoke()、ainvoke()、batch() 等方法,因此能够自然地放入 LCEL 链中。

从职责上看,可以这样区分:
| 对象 | 主要职责 | 常见调用方式 |
|---|---|---|
VectorStore |
保存向量,并提供底层搜索方法 | similarity_search() |
VectorStoreRetriever |
用向量库实现 Retriever 接口 | vector_store.as_retriever() |
1. 方法作用
as_retriever() 的作用是将底层向量库的原生检索能力,封装成统一、可插拔的 VectorStoreRetriever 对象。
2. 核心参数解析
该方法接受关键字参数(**kwargs),主要包含两个核心配置:
-
search_type(检索类型):定义检索器执行的搜索策略,默认是
similarity支持以下三种类型:
similarity:基础相似度搜索,优先返回与查询最相关的文档。mmr:最大边际相关性搜索(Maximal Marginal Relevance),在保持相关性的同时,最大化结果的多样性,避免返回大量重复内容。similarity_score_threshold:带相似度阈值的搜索,只返回得分高于指定阈值的文档,用于精准筛选。
-
search_kwargs(检索参数字典)传递给具体搜索函数的参数,常见的有:
k:返回的文档数量(默认为 4)。score_threshold:用于similarity_score_threshold模式,设定最低相关性得分。fetch_k:用于mmr模式,表示先拉取多少条候选文档来进行多样性计算(默认为 20)。lambda_mult:用于mmr模式,控制多样性权重。值为1表示最小多样性(纯相关),值为0表示最大多样性(默认为 0.5)。filter:通过文档的元数据(metadata)进行过滤。
三、哪些对象可以接入 LCEL?
使用 | 组合 LCEL 时,最直接的对象是实现了 Runnable 接口的组件。除此之外,LCEL 还会把部分 Python 对象自动转换成 Runnable。
1. 原生 Runnable 组件
常见组件包括:
- Prompt 模板,例如
ChatPromptTemplate、PromptTemplate。 - 聊天模型,例如
ChatQwen、ChatOpenAI。 - 输出解析器,例如
StrOutputParser、JsonOutputParser。 - 检索器,例如
VectorStoreRetriever。
Runnable 的核心特点是可以接收输入并产生输出,常用方法包括 invoke()、ainvoke()、batch() 和 stream()。
2. 普通函数会被转换为 RunnableLambda
像下面这样的普通函数,可以直接放在 | 后面:
python
def format_docs(docs: list[Document]) -> str:
return "\n\n".join(doc.page_content for doc in docs)
chain = retriever | format_docs
LCEL 会把 format_docs 转换成一个 RunnableLambda,因此它可以接收上一步的输出。
3. 字典会被转换为 RunnableParallel
在 LCEL 表达式中,字典表示多个命名分支:
python
parallel_step = {
"input": RunnablePassthrough(),
"context": retriever | format_docs,
}
chain = parallel_step | prompt
字典的 key 会成为输出字典的 key,value 则表示对应的处理步骤。这个字典在链中会被转换为 RunnableParallel。 源码如下: 
Mapping[str, Runnable[...] | Callable[...] | Any]- 这是最强大的部分之一。它允许右边是一个字典。
- 作用 :这会自动创建一个并行处理的结构(
RunnableParallel)。字典的 Key 是输出结果的键名,Value 是处理逻辑。 - 例子 :
RunnablePassthrough() | {"joke": joke_chain, "poem": poem_chain}。这会同时运行两个链,最后把结果拼成一个字典{"joke": "...", "poem": "..."}返回。
4. 静态值需要包装成 Runnable
普通字符串或数字只是数据,不是可以执行的链步骤。如果要在链中生成固定值,应使用函数或 RunnableLambda 包装:
python
from langchain_core.runnables import RunnableLambda
fixed_context = RunnableLambda(lambda _: "这是固定的参考资料")
这样,fixed_context 才能在被调用时返回固定内容。
四、字符串会同时流向字典里的两个分支 这怎么实现的?
这个机制在 LangChain 中被称为 RunnableParallel(并行执行)。
虽然代码里只写了一个普通的 Python 字典 {...},但 LangChain 的 LCEL 语法糖会自动把它转换成并行执行的逻辑。
具体实现原理如下:
核心机制:字典即并行
当你写 {"key": value} 并将其放入链中时,LangChain 会执行以下操作:
- 自动转换 :它会将这个字典包装成一个
RunnableParallel对象。 - 输入广播 :当链运行到这一步时,上游传下来的数据(在这里是用户输入的字符串
"怎么减肥?")会被同时复制并发送给字典里的每一个值(Value)。 - 独立执行:字典里的每个分支独立运行,互不干扰。
- 结果合并:所有分支运行结束后,它们的结果会被重新组装成一个新的字典,传递给下一步。
字符串 "怎么减肥?" 是如何流向两个分支的:
python
{
# 分支 1:直接透传
"input": RunnablePassthrough(),
# 分支 2:检索并格式化
"context": retriever | format_func
}
1. 数据分发(广播)
上游传来的字符串 "怎么减肥?" 同时进入两个分支:
- 给分支 1 :
RunnablePassthrough()接收到"怎么减肥?"。 - 给分支 2 :
retriever也接收到"怎么减肥?"。
这种同时流向两个分支的效果,不是靠多线程或异步并发实现的(虽然底层支持异步),而是靠 LCEL 的调度器逻辑实现的:它知道这是一个并行结构,因此会将同一个输入变量分别注入到字典的每一个值中去执行
2. 分支处理
- 分支 1 的处理:
RunnablePassthrough()什么都不做,直接原样返回"怎么减肥?"。
- 分支 2 的处理:
retriever拿着"怎么减肥?"去向量库搜索,返回[Document1, Document2]。format_func拿着这个列表,将其拼接成字符串"[减肥就是要少吃多练...]"。
3. 结果汇聚
当两个分支都跑完后,LangChain 会把结果拼起来,生成最终传给 Prompt 的字典:
python
{
"input": "怎么减肥?", # 来自分支 1
"context": "[减肥就是要少吃多练...]" # 来自分支 2
}
五、为什么需要 RunnablePassthrough?
既然字符串会自动分发给 retriever,为什么 input 还要专门写RunnablePassthrough()?
python
这是因为字典的值"(Value)"必须是一个可执行的对象(Runnable)。
当 LCEL 链运行到 "RunnableParallel"(也就是字典)时,调度器会遍历字典里的每一个 value。
"它拿到这个 value 后,必须调用一个统一的方法来启动它。这个方法就是 invoke()"。
+ 如果 value 是 `Runnable` 子类,调度器就可以直接调用 `value.invoke(输入数据)`。
+ 如果 value 是一个普通的字符串 `"怎么减肥?"`,调度器去调用 `"怎么减肥?".invoke() 就会直接报错(
AttributeError),因为普通字符串根本没有这个执行方法。
-
retriever | format_func本身就是一个复杂的 Runnable 链。 -
而原始的字符串
"怎么减肥?"只是一个数据,不是 Runnable。 -
所以我们需要
RunnablePassthrough()作为一个"占位符"或"搬运工",它的唯一任务就是接住数据并原样交出去,从而满足字典值的格式要求。
总结
similarity_search()是向量库的直接搜索方法,调用后立即返回list[Document]。as_retriever()会返回VectorStoreRetriever,让向量搜索具备标准 Runnable 接口。- 普通函数可以自动转换成
RunnableLambda,字典可以自动转换成RunnableParallel。 RunnableParallel会把同一个输入交给多个分支,并发执行后合并结果。RunnablePassthrough()负责保留原始输入,使用户问题可以和检索上下文一起交给 Prompt。