> ## 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.

# 10 · 重排序与对话式 RAG：从"能答"到"答得准"

# 10 · 重排序与对话式 RAG：从"能答"到"答得准"

> **本片目标**：解决第 09 篇遗留的两个质量问题——① 检索噪音（用重排序精排）；② 多轮追问失准（用历史感知检索）。
> **新增规定性：10**（两段式检索：粗排召回 + 精排重排；查询改写：结合历史）
> **数据字典**：CrossEncoder（sentence\_transformers）。
> **进程线程模型**：重排是本地 GPU/CPU 推理；历史感知检索是多一次模型调用。
> **网络模型**：重排本地；历史感知检索 = 1 次模型调用（改写查询）+ 1 次主问答调用。

***

## 1. 上集回顾

第 09 篇 RAG 能答了，但两个真实场景会翻车：

**场景 A：检索噪音**。问"转正需要什么条件"，向量检索召回 3 块，可能第 3 块是"自带电脑补助"——语义距离没拉开。模型被迫在噪音里找答案，容易被带偏。

**场景 B：多轮追问**。

```
用户：转正需要什么条件？
用户：那答辩要几位评委？        ← "那"指代转正条件，但检索器拿到的是"那答辩要几位评委"，匹配不到转正块！
```

第二个问题脱离上下文就无法检索。**必须结合对话历史改写查询**（"那答辩要几位评委" → "转正答辩需要几位评委打分"）。

***

## 2. 重排序：为什么需要两段式

**向量检索（粗排）是"快而粗"**：一次把千万块向量算相似度，秒级，但用的是**双塔模型**（query 和 doc 分别编码，最后算点积）——它牺牲精度换速度。

**CrossEncoder（精排）是"慢而准"**：把 query 和 doc **拼接成一条**喂给模型，做深度交叉注意力——精度高，但一次只能处理一对。

**生产方案 = 粗排捞 + 精排挑**：

```
问题 → 向量检索 k=8（粗排，快，多捞点别漏） → 8 块
     → CrossEncoder 逐块打分（精排，准）    → 取 top-3（只留最相关的）
```

**为什么不能全用精排**：知识库 1 万块，CrossEncoder 要跑 1 万次——太慢。先粗排砍到 8 块，再精排挑 3 块。**重排救不了粗排漏掉的块**——所以粗排 k 要足够大。

***

## 3. 数据字典：CrossEncoder

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

reranker = CrossEncoder(
    r"D:\work-space\pycharm-work\langchain_learn\models\bge-reranker-base",  # 本地模型路径
    max_length=512,
)

pairs = [("转正需要什么条件？", doc.page_content) for doc in rough_docs]
scores = reranker.predict(pairs)   # list[float]，每个 (query, doc) 对的分
```

| 参数/方法            | 类型                                     | 说明                                     |
| ---------------- | -------------------------------------- | -------------------------------------- |
| 第一个参数            | `str`                                  | 模型路径或 HF 模型名（`BAAI/bge-reranker-base`） |
| `max_length`     | `int`                                  | 单对文本最大长度                               |
| `predict(pairs)` | `list[tuple[str, str]] -> list[float]` | 逐对打分，分越高越相关                            |

**实测分数**（2026-08，`models/bge-reranker-base`）：

```
("转正需要什么条件？", 转正需试用期满、至少 60 篇日总结、答辩 75 分...)  → 0.987  ← 高度相关
("转正需要什么条件？", 自带电脑每月 120 元补助...)                    → 0.002  ← 无关
("转正需要什么条件？", 食堂午餐自带或外卖均可)                        → 0.000  ← 无关
```

区分度极高——重排能干净地把噪音块剔除。

***

## 4. 重排接入 RAG（代码形态）

```python theme={null}
rough_docs = retriever.invoke(question)                    # ① 粗排：k=8

pairs = [(question, d.page_content) for d in rough_docs]
scores = reranker.predict(pairs)                           # ② 精排：逐块打分

scored = sorted(zip(rough_docs, scores), key=lambda x: -x[1])  # ③ 降序
top_docs = [d for d, s in scored[:3]]                      # ④ 取 top-3

context = format_docs(top_docs)                            # ⑤ 拼上下文
answer = (prompt | model | StrOutputParser()).invoke(
    {"context": context, "question": question})
```

***

## 5. 对话式 RAG：历史感知检索（查询改写）

**问题本质**：第 06 篇的对话记忆 + 第 09 篇的 RAG 是两条独立的链，没打通。追问时检索器看到的是**残缺的问题**。

**解法（查询改写，无 agent、无 LangGraph）**：

```python theme={null}
# ① 用一个轻量模型调用，把"当前问题 + 历史"改写成一个可独立检索的完整问题
rewrite_prompt = ChatPromptTemplate.from_messages([
    ("system", "你是查询改写器。根据对话历史和最新问题，输出一个能独立检索知识库的完整问题。只输出问题本身。"),
    MessagesPlaceholder("history"),
    ("human", "最新问题：{question}"),
])

rewritten = (rewrite_prompt | model | StrOutputParser()).invoke(
    {"history": history.messages, "question": question})

# ② 用改写后的问题检索
rough_docs = retriever.invoke(rewritten)
```

**实测效果**：

```
历史: [user: 转正需要什么条件？, ai: 试用期满、至少 60 篇日总结、答辩平均分 75 分以上]
最新: 那答辩要几位评委？
改写: 转正答辩需要几位评委打分？      ← 独立可检索
```

***

## 6. 关键方法（本片完整链路）

| 环节   | 方法                        | 说明                |                   |              |
| ---- | ------------------------- | ----------------- | ----------------- | ------------ |
| 粗排   | `retriever.invoke(q)`     | k 调到 8（多捞）        |                   |              |
| 精排   | `reranker.predict(pairs)` | CrossEncoder 逐对打分 |                   |              |
| 查询改写 | \`rewrite\_prompt         | model             | StrOutputParser\` | 历史+问题 → 完整问题 |
| 生成   | \`prompt                  | model             | StrOutputParser\` | 第 09 篇的链     |

***

## 7. 进程线程模型

```
完整链路（对话式 + 重排）：
  question + history
    → 改写调用（模型 POST 1 次，阻塞 1~2s）      ← 新增网络往返
    → 粗排（本地 Chroma，~ms）
    → 精排（本地 CrossEncoder，8 对 ~几百 ms）
    → 生成（模型 POST 1 次，阻塞 1~3s）          ← 主回答
```

**代价意识**：对话式 RAG 把延迟从"1 次模型调用"变成"2 次模型调用"（改写 + 生成）。这是"准"的代价。生产级取舍见第 15 篇（可以只对多轮场景启用改写）。

***

## 8. 网络模型

```
改写：POST /v1/messages（历史 + 当前问题 → 完整问题）
生成：POST /v1/messages（context 资料 + 改写后问题 → 答案）
精排：本地推理，0 网络
```

两次模型调用都是标准 POST——没有新协议。区别只在**输入内容**：一次是"改写指令"，一次是"带资料的问答"。

***

## 9. 验证：跑起来

配套代码 `code/10_rerank.py`（分三幕）：

1. **重排演示**：粗排 k=8 → CrossEncoder 打分 → 观察分数分布（相关 0.98 vs 噪音 0.00）；
2. **重排接入 RAG**：对比有/无重排的答案质量；
3. **对话式 RAG**：两轮追问，观察改写前后检索命中变化。

```powershell theme={null}
cd enterprise-rag-course\code
python 10_rerank.py
```

**预期输出（节选）**：

```
===== ① 重排打分 =====
[资料1] 转正需试用期满、至少 60 篇日总结...  → 0.987  ← 留下
[资料4] 自带电脑每月 120 元补助...          → 0.002  ← 剔除
[资料7] 日总结命名格式...                   → 0.450  ← 边缘，视 top-3 名额

===== ② 对话式 RAG =====
用户：转正需要什么条件？
用户：那答辩要几位评委？
改写后问题: 转正答辩需要几位评委打分？
答: 转正答辩需要 4 位以上评委打分，平均分 75 分以上...
```

***

## 10. 边界

* **重排增加延迟**：CrossEncoder 逐对推理，粗排 k 越大越慢。k=8\~20 是常见区间。
* **改写会漂移**：改写模型可能改错原意。生产可在改写失败时回退用原始问题。
* **重排模型要下载**：`bge-reranker-base` 约 1.1GB（本机已在 `models/`）。也可用更小的 `bge-reranker-v2-m3`。
* **粗排 k 必须大于最终 k**：精排只从粗排结果里挑，粗排漏了精排救不了。

***

## 推荐资料（延伸阅读）

* [LangChain 官方文档 · 检索](https://docs.langchain.com/oss/python/langchain/retrieval) —— 检索器进阶（含重排概念）
* [sentence-transformers 文档](https://www.sbert.net/) —— CrossEncoder / bge-reranker 的底层库

***

## 11. 未完待续

RAG 已经完整（对话 + 重排），能回答文档问题了。但有个天花板：

> **模型只能"说"，不能"做"。** 用户问"转正答辩还有几天？"——手册里没有答案，得**算**出来。你不想为每个问题写死函数。

让模型在回答时**调用你写的工具**（算日期、查系统、发请求）——这就是第 11 篇：工具调用。

→ [11 · 工具调用](/doc/doc/enterprise-rag-course/11-工具调用)
