Day 12 / 共 20 天 · 阶段3 数据与 RAG

Retriever 与 RAG 链:把"翻书"变成一个 Runnable,然后用 | 拼出问答机器人

Day11 我们能按语义搜到相关文档了,但它还是个"孤立的库"。今天做两件事:①读懂 BaseRetriever——它把"检索"抽象成一个标准 Runnable(能 invoke、能进 LCEL 链、自动带回调追踪);②把 Day11 的向量库经 as_retriever() 一行升级,再和 Prompt、ChatModel 用 | 组装成一条完整的 RAG 问答链。这是阶段3 的收官——数据侧的所有零件今天正式合体。

📍 你在 20 天里的位置(阶段3:数据与 RAG · D09-12)
S1 LCEL S2 模型·消息·提示 D09 Document D10 文本切分 D11 Embeddings/向量库 D12 Retriever/RAG S4 工具/Agent S5 进阶收官
💡 先用两个类比兜住今天 类比一:Retriever 是图书馆的"管理员",VectorStore 是"书库"。Day11 的向量库像书库本身——能存能查,但你得懂它的内部规矩(similarity_search?with_score?k 传几?)。Retriever 是站在服务台的管理员:你只说一句话(query 字符串),他还你一摞相关的书页(list[Document]),至于他去书库用什么姿势找,是他的事。类比二:RAG 链 = 开卷考试。闭卷(直接问模型)容易瞎编;开卷是先让管理员把相关页翻出来夹在考卷里(塞进 prompt 的 context),模型照着材料答题。今天的链就是把"翻书→夹页→答题"三步用 | 串成流水线。
L01

痛点:检索这一步怎么"进链"?

🤔 痛点有了向量库,最朴素的 RAG 写法是手工三段式:docs = store.similarity_search(q) → 手工把 docs 拼成字符串塞进 prompt → 调模型。能跑,但问题一堆:换个检索源(向量库→ES→数据库)上层代码全改;想在 D03 学的 LCEL 链里用 | 接检索这一步,接不进去(它不是 Runnable);想在 LangSmith 里看到"这次检索用了什么词、返回了哪几条",没有埋点。检索需要一个和模型、Prompt 平级的"标准件"身份。
💡 本质:给"检索"发一张 Runnable 身份证LangChain 的答案是 BaseRetriever:固定输入输出为 str → list[Document],并继承 RunnableSerializable——于是任何检索器天生就有 invoke/batch/stream、能进 LCEL 链、自动挂回调。而"向量库检索"只是检索器的一种实现(VectorStoreRetriever),关键词检索、混合检索、多路召回……都能实现同一接口。上层链只认"管理员"这个岗位,不认某个具体的人。
L02

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](相关文档)
    ...

公开入口 invokeretrievers.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、模型是"同一种积木"。
L03

as_retriever:向量库一行升级成检索器

Day11 结尾埋的钩子今天揭开。VectorStore.as_retrieverlibs/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)

产出的 VectorStoreRetrieverbase.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_typebase.py:987)在构造时就检查:search_type 拼错直接报错;选了 similarity_score_threshold 却没给 float 阈值也报错——把错误拦在构造期,别等运行期
大白话一句 retriever = store.as_retriever(search_kwargs={"k": 3}),读作:"给这个书库配个管理员,以后每次问他,他都取最相关的 3 页回来。"从此你手里的东西就是个标准 Runnable,能 retriever.invoke("请假规定"),也能直接 | 进链。
L04

三种检索策略的分发:一个 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 条都是近似重复文档间高度雷同(多版本文档、模板化内容)
💡 设计取舍:为什么策略挂在 Retriever 而不是 VectorStore?向量库只提供能力(三个 search 方法都有),检索器负责决策(这个场景用哪个、参数是多少)。同一个库可以配出多个不同性格的管理员:store.as_retriever(search_kwargs={"k": 8}) 给召回率优先的场景,store.as_retriever(search_type="similarity_score_threshold", search_kwargs={"score_threshold": 0.8, "k": 3}) 给精度优先的场景——数据一份,策略多套
L05

组装一条 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 链的数据流:一个问题兵分两路,再合流答题 "请假提前几天?" retriever(翻书 k=3) | format_docs 拼材料 RunnablePassthrough prompt 填模板 model | parser {"context": 材料, "question": 原问题}
图注:dict 即 RunnableParallel——上路开卷找材料,下路带着原题,合流后填进模板让模型照材料作答。
⚠️ 坑:检索质量决定 RAG 上限链跑通 ≠ 答得好。如果 Day10 切分切碎了关键段落、k 给小了没召回、或者该用 threshold 挡噪音没挡——模型再强也只能"照着错误材料一本正经地答"。调 RAG 优先调检索侧(看 LangSmith 里 Retriever 节点返回的文档对不对),而不是先改提示词。
L06

串起来 + 今日小结

📝 真实值:rag_chain.invoke 一步步发生了什么 rag_chain.invoke("请假要提前几天申请?") → RunnableParallel 两路并发:分支A retriever.invoke("请假要提前几天申请?") → 走 L02 的模板方法 → _get_relevant_documentssearch_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  # 三种策略分发
明日预告 · Day 13:阶段3 完结,进入阶段4"工具与 Agent"。RAG 是"给模型喂材料",工具是"给模型发扳手"——明天读 core/tools/:一个 @tool 装饰器怎么把普通 Python 函数变成模型能看懂的工具(名字/说明书/参数 schema 从哪来),以及 BaseTool/StructuredTool 的家族关系。
← Day 11 Embeddings 与 VectorStore Day 13 · Tool 抽象 →