> ## Documentation Index
> Fetch the complete documentation index at: https://www.yuan111.asia/doc/llms.txt
> Use this file to discover all available pages before exploring further.

# 09 · 知识库问答：RAG 完整链路（老板要的东西终于来了）

> RAG 完整链路：检索 + 生成，粗排 + 重排序，让模型基于你的文档回答。

# 09 · 知识库问答：RAG 完整链路（老板要的东西终于来了）

## 开头的现象

小林把零件都备齐了：能回答的模型、能切块的刀、能按意思检索的向量库。他以为自己马上能交货，结果老板发来一条消息："小林，手册我更新了，以后每年更新一次。你要让机器人永远答最新版，不能我改完了它还念旧版本。"

小林心想：**"这怎么可能？模型是训练出来的，我又不能重新训练它。"** 他抱着这个想法，去查了一个他早有耳闻但一直没搞懂的词——RAG。

## 第一幕：小林的"死记硬背"幻想破灭了

小林一开始的设想是"把手册背给模型听"——他试了把手册全文塞进 prompt，模型答得驴唇不对马嘴（第 7 篇已经栽过）。他又想"微调模型"——查了查价格和工期，直接放弃。

他沮丧地坐在工位上，突然看到了一个词：**RAG（检索增强生成，Retrieval-Augmented Generation）**。文档里写了一句让他豁然开朗的话：

> **RAG 不改变模型，改变模型"看到什么"。每次用户提问，先从你的文档里检索最相关的段落，把段落塞进 prompt 让模型基于它回答。**

小林一拍大腿：**"我不是要让模型记住手册，我是要让模型在回答的时候'翻手册'！"** 他脑海里浮现出一个画面：模型是个新来的实习生，不懂公司规矩，但每次被问到"年假怎么算"，你就把员工手册翻到对应那页递给他看——**他照着念，永远是对的，手册更新了他就念新版。**

这就是 RAG 的灵魂：**检索（翻到哪页）+ 生成（照着念）。**

> 想验证"翻手册"vs"背手册"的区别吗？跑 `09_rag.py` 问它"工资什么时候发"——它的回答里带着手册原文的影子，而且手册里没有的信息它会说"手册里没有相关信息"（不编造）。这就是"照着念"的表现。

## 第二幕：小林画出了 RAG 的"流水线"——四条链串起来

小林把 RAG 拆成四步，画了一张图贴在显示器上：

```text theme={null}
用户问题
   │
   ▼
① 检索器 Retriever ──从向量库找出最相关的 3 块──→ [块A, 块B, 块C]
   │
   ▼
② 格式化 ──把块拼成一段"【资料】"文字──→ "[资料1] 年假满一年5天...\n[资料2] ..."
   │
   ▼
③ 提示词模板 ──把资料和问题填进模板──→ SystemMessage(只能根据【资料】回答) \+ HumanMessage(问题)
   │
   ▼
④ 模型生成 ──基于资料回答问题──→ "根据员工手册，年假满一年 5 天……"
```

他用 LangChain 的链式语法（`|`）把这四步串成了一条"流水线"：

```python theme={null}
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser

# ① 检索器：把向量库变成"检索器"
retriever = vector_store.as_retriever(search_kwargs={"k": 3})   # 每次取 3 块

# ② 格式化：把检索到的块拼成一段资料文字
def format_docs(docs):
    return "\n\n".join(f"[资料{i\+1}] {d.page_content}" for i, d in enumerate(docs))

# ③ 提示词模板：告诉模型"只能根据资料回答，没有就说不知道"
prompt = ChatPromptTemplate.from_messages([
    ("system", "你是公司手册问答助手。只能根据下面【资料】回答。资料里没有的，明确说'手册里没有相关信息'。\n\n【资料】\n{context}"),
    ("human", "{question}"),
])

# ④ 串成一条链（LCEL）：问题 → 检索\+格式化 → 填模板 → 模型 → 取文本
rag_chain = (
    {"context": retriever | format_docs,   # 左边：检索 \+ 拼资料
     "question": RunnablePassthrough()}    # 右边：原样传问题
    | prompt                               # 填进模板
    | model                                # 模型生成
    | StrOutputParser()                    # 剥出纯文本
)
```

小林盯着这条链，心里第一次有了"掌控感"：**`|` 就像流水线上的传送带，左边输出接到右边输入。`{"context": ..., "question": ...}` 是并行两条管道——一个去检索资料，一个原样递问题，最后汇合填进模板。**

他问了一个手册里没有的问题："公司食堂中午有什么菜？"——模型回答："手册里没有相关信息。" 小林满意地点头：**"它没编！它知道资料里没有就不瞎说！"**

> 想验证这条流水线吗？跑 `09_rag.py`，四个问题轮流问：有的命中手册（年假、工资、迟到），有的是手册没有的（食堂菜单）——模型会诚实地说"手册里没有相关信息"。这就是 RAG 的"不说谎"。

## 第三幕：小林发现了检索的"最后一公里"问题——粗排 vs 精排

小林用了一天，发现了一个让他皱眉的现象：**检索器返回的 top-3，有时候不是最相关的 3 块。**

他做了个实验：问"年假怎么算？"，让检索器返回 top-8，他一个个看过去：

```text theme={null}
[0] 年假原则上应在本年度内休完……         ← 相关（但只说"怎么休"，没说"怎么算"）
[1] 第三章 请假制度  年假天数根据工龄计算…  ← 最相关！（年假几天、怎么算，全在这块）
[2] 第四章 薪酬福利  工资于每月十号发放…   ← 不太相关（说的是工资）
[3] 加班需要提前向直属主管申请……          ← 不相关
[4] 员工入职满一年后，可参加公司年度体检…   ← 不相关
```

**问题来了**：最相关的 \[1] 排在第二位。如果只取 top-1，模型看到的会是"年假怎么休"而不是"年假怎么算"——**检索对，答案可能还是错。**

小林查了下自己用的检索方式，才知道它叫\*\*"粗排"**（或叫"召回"）：用向量相似度（cosine 距离）快速筛出候选，图的是"快"和"全"——宁可多捞一些，别漏掉相关的。但**向量相似 ≠ 语义相关\*\*：两个句子在向量空间离得近，不代表它们真的在讲同一件事。

他想起一个词——**重排序（Rerank）**。老王告诉他：**粗排负责"捞"，精排负责"挑"。** 生产环境的 RAG 通常是两段式：

```text theme={null}
粗排（向量检索，快，捞 top-50）
   │
   ▼
重排序（reranker 模型，慢一点但准，把最相关的挑到最前）
   │
   ▼
取精排后的 top-3 → 塞进 prompt
```

**为什么不能只靠粗排？** 因为向量检索的相似度是"整体语义近似"，容易把"都提到年假"但"其实在讲别的"的块排前面。重排序模型（reranker）是**交叉编码器（CrossEncoder）**——它把"问题 + 每一块资料"拼在一起，让模型同时看两边，逐块打分，分数就是"这块对这个问题有多相关"。**它比向量检索贵，但对"相关度"的判断准得多。**

小林掏出代码验证——他用的是本地中文重排序模型 **`BAAI/bge-reranker-base`**（和 embedding 的 bge 是同一个家族），配合 `sentence_transformers` 的 `CrossEncoder`：

```python theme={null}
from sentence_transformers import CrossEncoder

# 本地中文重排序模型（和 08 篇的 embedding 同一家：BAAI/bge 系列）
reranker = CrossEncoder(
    r"D:\work-space\pycharm-work\langchain_learn\models\bge-reranker-base",
    max_length=512,
)

# ① 粗排：向量检索捞 top-8
rough = retriever.invoke("年假怎么算？")     # k=8

# ② 精排：问题 \+ 每块资料拼起来逐块打分
pairs = [("年假怎么算？", d.page_content) for d in rough]
scores = reranker.predict(pairs)             # 每块一个分数

# ③ 按分数降序取 top-3
scored = sorted(zip(rough, scores), key=lambda x: -x[1])[:3]
```

他打印出精排后的结果，和粗排对比：

```text theme={null}
Q: 年假怎么算？
  粗排: [0]年假怎么休  [1]请假制度(年假计算)  [2]工资几号发
  精排: [1]请假制度(年假计算)  0.83   ← 最相关的提到第一！
        [0]年假怎么休       0.75
        [3]加班申请         0.20   ← 不相关的被挤走
```

**小林的顿悟**：粗排的 \[2] 工资（0.08 分）被精排挤出了 top-3，换进来的是 \[3] 加班（虽然也不完美，但分数逻辑对了——它把"高相关"的提到前面、"低相关"的压到后面）。**粗排负责"别漏"，精排负责"别错"。** 这个 0.83 vs 0.08 的分数差，就是 reranker 的价值——它能分清"谁真的在回答这个问题"。

> 想验证吗？下载 `BAAI/bge-reranker-base` 模型（约 1.1GB，放 `models/bge-reranker-base`），跑 `09_rag_rerank.py`——你会看到同一批 top-8 粗排结果，重排后顺序变了，且每块带一个 relevance 分数。**想验证"向量检索会犯错"吗？** 在粗排结果里故意找一块"提到年假但实际讲别的"的资料，看它精排后被压到多后面。

## 第四幕：小林解剖了链的"中间产物"——模型到底看到了什么

小林的好奇心又来了：这条链最后 `StrOutputParser()` 剥出纯文本，那剥壳之前模型返回的到底是什么？

他拆了一截"只到模型为止"的链：

```python theme={null}
chain_before_parser = (
    {"context": retriever | format_docs, "question": RunnablePassthrough()}
    | prompt
    | model      # 到这里停下：还没剥壳
)
mid = chain_before_parser.invoke("年假是怎么计算的？")
print(mid.content)   # 一个 AIMessage！
```

他打印了 `mid` 的完整结构，发现：**模型在 RAG 里返回的还是一个 AIMessage**——`content` 是最终回答，`response_metadata.stop_reason` 是 `end_turn`（直接回答完毕，没调工具），`usage_metadata` 记着 token。

他恍然大悟：**`StrOutputParser` 只做一件事——把 `AIMessage.content` 剥出来变成字符串。** 整条链的"最后一环"，就是个拆包装的。

> 想验证"剥壳前长什么样"吗？把 `09_rag.py` 里链尾的 `StrOutputParser()` 去掉，直接 `invoke`——你拿到的不是一个字符串，而是一个完整的 AIMessage（content 是回答，response\_metadata 里躺着 stop\_reason 和 usage）。加上 `StrOutputParser()`，就只剩文本了。

### 🔧 技术细节：这一章涉及的关键类和签名

**① `BaseRetriever`——检索器接口（langchain\_core.retrievers）**

```python theme={null}
retriever.invoke(query: str) -> list[Document]   # 标准入口
retriever.batch(queries) -> list[list[Document]]
# 自定义检索器：继承 BaseRetriever，实现 _get_relevant_documents(query)
```

**② 链式语法（LCEL）——`|` 背后的类型**

```python theme={null}
# a | b：把 a 的输出接到 b 的输入
retriever | format_docs    # Retriever → Document列表 → 拼接字符串
prompt | model             # 消息列表 → AIMessage
model | StrOutputParser()  # AIMessage → str
# 每个环节都是 Runnable，invoke() 一路传递
```

**③ `RunnablePassthrough`——透传（RAG 里"原样递问题"）**

```python theme={null}
RunnablePassthrough()                  # 输入啥输出啥
RunnablePassthrough.assign(**kwargs)   # 输入 \+ 塞新键
# {"context": ..., "question": RunnablePassthrough()}
#  → context 走检索，question 原样传递，两边汇合进模板
```

**④ 四个环节的类型全览：**

```python theme={null}
rag_chain = (
    {"context": retriever | format_docs,     # dict[str, str]
     "question": RunnablePassthrough()}
    | prompt                                  # ChatPromptTemplate → 消息列表
    | model                                   # BaseChatModel → AIMessage
    | StrOutputParser()                       # → str（最终答案）
)
```

> 想验证吗？`rag_chain.invoke("年假怎么算？")` 返回字符串；去掉 `StrOutputParser()` 直接 invoke，返回 AIMessage——看两者的 type 区别。

**⑤ 重排序（第三幕）——`CrossEncoder` 与两段式检索**

```python theme={null}
from sentence_transformers import CrossEncoder

# 本地中文重排序模型（sentence-transformers 家族，与 bge 系列同源）
reranker = CrossEncoder(r"...\models\bge-reranker-base", max_length=512)

# 精排：问题 \+ 每块拼成 pair，逐块打分
pairs = [(query, doc.page_content) for doc in rough_docs]   # rough_docs 来自粗排 top-N
scores = reranker.predict(pairs)                            # list[float]，一一对应
# 按分数降序取精排后的 top-k（分数高 = 对当前问题更相关）

# 生产常用：粗排 k 调大（如 20），精排后再取 top-3 → 塞进 prompt
retriever = vector_store.as_retriever(search_kwargs={"k": 20})   # 粗排多捞
# → 精排 → top_3 → format_docs → prompt
```

**关键认知**：粗排（向量检索）快但"相似 ≠ 相关"；精排（CrossEncoder）把问题和每块资料**拼在一起看**，逐块打分，准但贵。所以两段式：粗排负责"别漏"，精排负责"别错"。**重排只能重新排序已捞到的块，捞漏了的它救不回来。**

## 第五幕：边界——RAG 不是银弹

小林用了一天，摸清了 RAG 的边界：

**它能干的**：让模型基于"你的文档"回答问题、自动跟进文档更新（换库内容就行，不用重新训练）、控制模型"不乱编"（资料里没有就说不知道）、给回答标出处（块里的 metadata）。

**它不能干的**：

* **检索错了，答案就错**——RAG 的上限取决于检索（第 8 篇那些边界：否定、数字、专有名词）。检索返回了不相关的块，模型再聪明也答不对。**重排序（第三幕）能缓解但治不了本**：它只能把"已捞到的块"重新排序，捞漏了的（粗排没召回）它也无能为力。
* **资料本身有错，模型照样念**——RAG 不校验资料对错，只保证"照着念"。
* **不适合"开放式创意问题"**——问"帮我想个宣传语"，RAG 的"翻手册"反而碍事。
* **多轮对话没接上**——这个版本的 RAG 是无状态的，每次提问独立检索。要让它"记得上一轮"，得接上第 4 篇的历史机制（或直接上第 08、09 篇的 Agent / LangGraph）。

> 想验证边界吗？问 `09_rag.py` 一个手册里根本没有答案的问题，比如"食堂菜单"——它老实说"手册里没有"。但如果你在手册里写一句错误信息（比如故意把年假天数写错），RAG 会**照错念**——它不校验内容对错。

## 结论（小林用一天换来的）

**RAG = 检索 + 生成：每次提问先"翻手册"（从向量库找最相关的 3 块），再把资料 + 问题填进模板让模型"照着念"。** LangChain 用 `|` 把 检索器 → 格式化 → 模板 → 模型 → 解析器 串成一条链，`StrOutputParser` 负责剥壳取文本。**检索分两段：粗排（向量相似度，快、捞 top-N）→ 重排序（CrossEncoder 精排，准、挑最相关）**——粗排负责"别漏"，精排负责"别错"。**边界：RAG 的上限=检索的上限，检索错就答错（重排序只能缓解不能根治）；它不校验资料对错，也不自带记忆。** 但这一刻，小林终于把老板要的"基于文档回答问题"跑通了——虽然它还只是个"无状态问答机"。

## 复现信号：什么时候你会想起这一章

1. **要让模型回答"你的文档里的问题"**——别微调，用 RAG：切块 → 向量化 → 粗排检索 → **重排序** → 生成。
2. **模型总在编造"手册里没有的信息"**——RAG 的 system 提示"没有就说不知道"能压住它。
3. **文档经常更新**——RAG 只要换库内容，模型不用动。
4. **回答要"有理有据"**——检索块的 metadata 存好来源，回答就能标出处。
5. **检索返回 top-3 但答案还是不对**——可能是粗排把"提到关键词但不相关"的块排前面了：加大粗排 k（比如 top-20），再加重排序精排到 top-3。

小林交差了吗？还没有。老板试用了一下说："我问它'去年这时候问过你什么'，它一脸懵——它不记得我跟它聊过天！" 小林叹了口气，他知道下一步了：给这个问答机加上**记忆、工具、还有自主决策**——他要让"问答机"进化成"助手"，[翻到第 10 篇：会记住、会干活的助手](/doc/doc/narrative-course/10-记忆工具Agent)。
