Retriever 与 RAG 链:把"翻书"变成一个 Runnable,然后用 | 拼出问答机器人
Day11 我们能按语义搜到相关文档了,但它还是个"孤立的库"。今天做两件事:①读懂 BaseRetriever——它把"检索"抽象成一个标准 Runnable(能 invoke、能进 LCEL 链、自动带回调追踪);②把 Day11 的向量库经 as_retriever() 一行升级,再和 Prompt、ChatModel 用 | 组装成一条完整的 RAG 问答链。这是阶段3 的收官——数据侧的所有零件今天正式合体。
| 串成流水线。痛点:检索这一步怎么"进链"?
docs = store.similarity_search(q) → 手工把 docs 拼成字符串塞进 prompt → 调模型。能跑,但问题一堆:换个检索源(向量库→ES→数据库)上层代码全改;想在 D03 学的 LCEL 链里用 | 接检索这一步,接不进去(它不是 Runnable);想在 LangSmith 里看到"这次检索用了什么词、返回了哪几条",没有埋点。检索需要一个和模型、Prompt 平级的"标准件"身份。BaseRetriever:固定输入输出为 str → list[Document],并继承 RunnableSerializable——于是任何检索器天生就有 invoke/batch/stream、能进 LCEL 链、自动挂回调。而"向量库检索"只是检索器的一种实现(VectorStoreRetriever),关键词检索、混合检索、多路召回……都能实现同一接口。上层链只认"管理员"这个岗位,不认某个具体的人。BaseRetriever:invoke 是壳,_get_relevant_documents 是芯
看 libs/core/langchain_core/retrievers.py:55——类签名一行就交代了输入输出契约:
# libs/core/langchain_core/retrievers.py:55
class BaseRetriever(RunnableSerializable[RetrieverInput, RetrieverOutput], ABC):
# RetrieverInput = str(查询词), RetrieverOutput = list[Document](相关文档)
...
公开入口 invoke(retrievers.py:179)是一个标准的"模板方法"——包一圈回调,把真活委托给子类:
# libs/core/langchain_core/retrievers.py:179(裁剪)
def invoke(self, input: str, config=None, **kwargs) -> list[Document]:
config = ensure_config(config)
callback_manager = CallbackManager.configure( # ① 装配回调(LangSmith 追踪靠它)
config.get("callbacks"), None, ...,
inheritable_tags=config.get("tags"), local_tags=self.tags, ...)
run_manager = callback_manager.on_retriever_start( # ② 记录"检索开始"事件
None, input, name=config.get("run_name") or self.get_name(), ...)
try:
result = self._get_relevant_documents( # ③ ★真正的检索:交给子类
input, run_manager=run_manager)
except Exception as e:
run_manager.on_retriever_error(e) # ④ 失败也上报
raise
else:
run_manager.on_retriever_end(result) # ⑤ 记录"检索结束+结果"
return result
子类唯一必须实现的抽象方法(retrievers.py:298):
# libs/core/langchain_core/retrievers.py:298
@abstractmethod
def _get_relevant_documents(
self, query: str, *, run_manager: CallbackManagerForRetrieverRun
) -> list[Document]:
"""Get documents relevant to a query."""
模板方法模式★invoke(壳)负责回调、异常上报这些"人人都要的杂务",只写一次;_get_relevant_documents(芯)才是各家自由发挥的检索逻辑。你自己写检索器时,只需实现芯,杂务白送。这和 Day05 ChatModel 的 invoke → _generate、后面 Day13 Tool 的 run → _run 是同一个模式,LangChain 全家都这么长。on_retriever_start/end检索有自己专属的回调事件——所以在 LangSmith 里能看到独立的 Retriever 节点:查询词是什么、返回了哪几条、耗时多少。RAG 效果不好时,第一件事就是看这个节点:是没搜到,还是搜到了模型没用好。RunnableSerializable继承它 = 领到 Runnable 全家桶:invoke/ainvoke/batch/stream、| 组合、with_config……检索器从此和 Prompt、模型是"同一种积木"。as_retriever:向量库一行升级成检索器
Day11 结尾埋的钩子今天揭开。VectorStore.as_retriever(libs/core/langchain_core/vectorstores/base.py:905)去掉长长的 docstring 后只有两行(base.py:960-961):
# libs/core/langchain_core/vectorstores/base.py:960
tags = kwargs.pop("tags", None) or [*self._get_retriever_tags()]
return VectorStoreRetriever(vectorstore=self, tags=tags, **kwargs)
产出的 VectorStoreRetriever(base.py:964)本质是"向量库 + 检索策略配置"的组合体:
# libs/core/langchain_core/vectorstores/base.py:964
class VectorStoreRetriever(BaseRetriever):
"""Base Retriever class for VectorStore."""
vectorstore: VectorStore # 背后的书库(Day11 的对象)
search_type: str = "similarity" # 用哪种找法,默认相似度
search_kwargs: dict[str, Any] = Field(default_factory=dict) # k、filter、阈值…
allowed_search_types: ClassVar[Collection[str]] = (
"similarity", # 纯相似度 top-k
"similarity_score_threshold", # 带分数下限
"mmr", # 最大边际相关:相关性+多样性
)
vectorstore=self检索器持有向量库,不复制数据——它只是给书库配了个管理员,书还是那些书。库里后续 add_documents 的新内容,检索器立刻能搜到。search_type / search_kwargs把"怎么搜"做成了数据(配置)而非代码:as_retriever(search_type="mmr", search_kwargs={"k": 6, "lambda_mult": 0.25})。docstring(base.py:932 起)给了一整页真实示例:卡阈值 0.8、只取 1 条、按 metadata 过滤……校验器兜底validate_search_type(base.py:987)在构造时就检查:search_type 拼错直接报错;选了 similarity_score_threshold 却没给 float 阈值也报错——把错误拦在构造期,别等运行期。retriever = store.as_retriever(search_kwargs={"k": 3}),读作:"给这个书库配个管理员,以后每次问他,他都取最相关的 3 页回来。"从此你手里的东西就是个标准 Runnable,能 retriever.invoke("请假规定"),也能直接 | 进链。三种检索策略的分发:一个 if 链就讲完
VectorStoreRetriever 实现的"芯"(base.py:1040)就是把 search_type 翻译成 Day11 学过的那几个方法:
# libs/core/langchain_core/vectorstores/base.py:1040
def _get_relevant_documents(self, query, *, run_manager, **kwargs) -> list[Document]:
kwargs_ = self.search_kwargs | kwargs # 配置合并:调用时可临时覆盖
if self.search_type == "similarity":
docs = self.vectorstore.similarity_search(query, **kwargs_) # ① 纯 top-k
elif self.search_type == "similarity_score_threshold":
docs_and_similarities = (
self.vectorstore.similarity_search_with_relevance_scores( # ② 先拿分数
query, **kwargs_))
docs = [doc for doc, _ in docs_and_similarities] # 低于阈值的已被过滤
elif self.search_type == "mmr":
docs = self.vectorstore.max_marginal_relevance_search(query, **kwargs_) # ③ 去同质化
else:
raise ValueError(f"search_type of {self.search_type} not allowed.")
return docs
| search_type | 行为 | 什么时候用 |
|---|---|---|
similarity | 无脑取最相似的 k 条 | 默认;库内容干净、不怕沾边噪音 |
similarity_score_threshold | 相似度不到阈值的宁可不要 | 怕"硬凑 k 条"引入无关内容误导模型 |
mmr | 相关性和多样性折中,避免 k 条都是近似重复 | 文档间高度雷同(多版本文档、模板化内容) |
store.as_retriever(search_kwargs={"k": 8}) 给召回率优先的场景,store.as_retriever(search_type="similarity_score_threshold", search_kwargs={"score_threshold": 0.8, "k": 3}) 给精度优先的场景——数据一份,策略多套。组装一条 RAG 问答链:检索 + 提示 + 模型 合体
零件齐了:Retriever 是 Runnable(今天),Prompt 是 Runnable(D07),ChatModel 是 Runnable(D05),解析器是 Runnable(D08)。用 D03 的 | 拼起来:
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough
retriever = store.as_retriever(search_kwargs={"k": 3}) # Day11 的库 + 今天的管理员
def format_docs(docs): # list[Document] → 一段文本
return "\n\n".join(doc.page_content for doc in docs)
prompt = ChatPromptTemplate.from_template(
"只根据以下材料回答问题,材料里没有就说不知道。\n材料:\n{context}\n\n问题:{question}")
rag_chain = (
{"context": retriever | format_docs, # ① 并行分支A:问题→翻书→拼材料
"question": RunnablePassthrough()} # ② 并行分支B:问题原样透传
| prompt # ③ 填模板 → 消息列表
| chat_model # ④ 开卷答题
| StrOutputParser() # ⑤ AIMessage → 纯字符串
)
answer = rag_chain.invoke("请假要提前几天申请?")
{"context": ..., "question": ...}dict 字面量在 LCEL 里自动变成 RunnableParallel(D03 学过):同一个输入(问题字符串)兵分两路——一路去检索并拼成材料,一路原样透传当问题。两路结果合成 {"context": "...", "question": "..."} 喂给 prompt。retriever | format_docs检索器输出 list[Document],模板要的是字符串——中间垫一个普通函数(LCEL 自动把它包成 RunnableLambda)。Retriever 能出现在 | 左边,正是 L02 继承 Runnable 换来的待遇。"没有就说不知道"RAG 提示词的经典护栏:明确要求模型只依据材料作答,显著降低幻觉。开卷考试要在考卷上印"答案必须出自材料"。串起来 + 今日小结
rag_chain.invoke("请假要提前几天申请?") → RunnableParallel 两路并发:分支A retriever.invoke("请假要提前几天申请?") → 走 L02 的模板方法 → _get_relevant_documents 按 search_type="similarity" 调 similarity_search(k=3) → 返回 3 个 Document(第 1 条就是"员工休假需至少提前三个工作日提交 OA 流程")→ format_docs 拼成一段材料;分支B 原样输出问题字符串 → 合成 dict → prompt 填出完整消息 → 模型读材料回答:"根据材料,请假(休假)需要至少提前三个工作日提交 OA 流程。" → StrOutputParser 剥出纯文本。整条链每一节都是 Day03-11 学过的标准件。👶 小白:Retriever 和 VectorStore 感觉功能重叠——都能"查",为什么要两个东西?
👨🏫 老师:分工不同。VectorStore 是"存储层":接口宽(增删查、多种 search、带分数、按向量搜……),面向"管理数据的人"。Retriever 是"服务层":接口极窄(str 进、list[Document] 出),面向"用数据的链"。窄接口才好组合、好替换——明天你把向量检索换成 ES 关键词检索、甚至"多路召回再融合",只要还是 str→list[Document],RAG 链一个字不用改。宽接口管数据,窄接口进流水线,这是分层设计的经典味道。
🧠 今天你应该能回答
BaseRetriever的输入输出契约是什么?(str → list[Document],且是 Runnable)- invoke 和 _get_relevant_documents 什么关系?(模板方法:壳管回调/异常,芯管检索逻辑,自定义只写芯)
as_retriever()做了什么?(两行:构造 VectorStoreRetriever,持有向量库 + search_type/search_kwargs 配置)- 三种 search_type 各解决什么?(similarity 保量 / threshold 保质 / mmr 保多样)
- RAG 链里 dict 字面量的作用?(RunnableParallel:context 走检索、question 透传,合流喂 prompt)
- RAG 答错了先查哪?(先看检索节点召回的文档对不对,再调提示词)
✋ 10 分钟动手
cd /Users/bitmart/work/codes/github/AI_WORK/langchain
# 1. BaseRetriever:契约 + 模板方法 + 抽象芯
sed -n '55,70p' libs/core/langchain_core/retrievers.py
sed -n '179,236p' libs/core/langchain_core/retrievers.py
sed -n '297,310p' libs/core/langchain_core/retrievers.py
# 2. as_retriever 与 VectorStoreRetriever
sed -n '905,965p' libs/core/langchain_core/vectorstores/base.py # docstring 里全是真实用法
sed -n '964,985p' libs/core/langchain_core/vectorstores/base.py # 三个字段
sed -n '1040,1058p' libs/core/langchain_core/vectorstores/base.py # 三种策略分发
core/tools/:一个 @tool 装饰器怎么把普通 Python 函数变成模型能看懂的工具(名字/说明书/参数 schema 从哪来),以及 BaseTool/StructuredTool 的家族关系。