本课目录(17 节)
🎯 本节目标
学完这一节,你能:
- 配置混合检索(向量 + BM25 全文),解决"语义像但关键词丢"问题
- 接入 Rerank 模型(BGE-Reranker),把准确率从 60% 拉到 90%
- 用 RAGAS 评估 RAG 质量(faithfulness / answer relevancy)
🍎 生活化类比
单向量检索 = 只看"意思像不像"。混合检索 = 意思 + 关键词双保险。Rerank = 复赛精选。
- 用户问"2024 年 Q3 营收":向量检索可能召回"2023 Q2 利润"(意思像但年份错)
- 加 BM25 全文检索:"2024""Q3""营收"这些关键词必须命中
- 两路结果合并后,Rerank 用更强的模型精排,挑出真正最相关的
📖 概念讲解
为什么单向量不够
| 检索方式 | 强项 | 弱项 |
|---|---|---|
| 纯向量(语义) | 理解同义词、语义 | 关键词(年份、编号、专有名词)容易丢 |
| 纯全文(BM25) | 关键词精确 | 不懂同义词 |
| 混合(生产必选) | 两者互补 | 略复杂 |
Rerank:召回之后的精排
向量库一次召回 20-50 条(怕漏),再用更强的 Rerank 模型(BGE-Reranker / Cohere)逐对打分,挑出 top 3-5 塞给大模型。这一步能把准确率拉 20-30 个点。
RAGAS 四指标
| 指标 | 含义 | 目标 |
|---|---|---|
| faithfulness | 回答是否忠于检索内容(不编) | ≥ 0.8 |
| answer_relevancy | 回答是否切题 | ≥ 0.8 |
| context_precision | 检索的片段是否精准 | ≥ 0.7 |
| context_recall | 该召回的有没有漏 | ≥ 0.7 |
🛠️ 动手实操:Dify 配置混合检索 + Rerank
(沿用模块 2 的 Dify 部署)
- 知识库设置:
- 检索模式:混合检索(起步用向量 0.5 + 全文 0.5 对半配,这是默认起点;后面常见错误表会讲怎么按业务调,比如偏术语精确匹配的业务往全文加权重)
- Rerank 模型:
bge-reranker-v2-m3 - Top K:召回 20,Rerank 后取 5
- 准备 20 个测试问题 + 标准答案
- 用 RAGAS 跑评估:
pip install ragas
from ragas import evaluate
from ragas.metrics import faithfulness, answer_relevancy
# 准备 dataset:question/answer/context/ground_truth
# result = evaluate(dataset, metrics=[faithfulness, answer_relevancy])
# 目标:faithfulness ≥ 0.8
✅ 这是智谱×公积金 12329 把正确率从 60% 拉到 90%+ 的核心方法。
⚠️ 常见错误
| 问题 | 原因 | 解决 |
|---|---|---|
| 混合检索后效果反而更差 | 向量和全文权重没调好 | 先各跑一遍看单路效果,再调权重(通常向量 0.6 + 全文 0.4) |
| Rerank 后 top 反而变了 | Rerank 模型和 Embedding 不匹配 | 用同家族(BGE Embedding 配 BGE Reranker) |
| faithfulness 达不到 0.8 | 检索召回的片段不准 | 先治检索(context_precision),再测生成 |
✅ 自测题
<summary>1. 为什么纯向量检索不够,要加 BM25 全文?</summary>向量懂语义但丢关键词。用户问"2024 Q3 营收",向量可能召回"2023 Q2 利润"(语义像但年份错)。BM25 保证"2024""Q3""营收"这些关键词精确命中。混合检索两者互补。
</details> <summary>2. Rerank 在 RAG 里起什么作用?为什么能提 20-30 个点?</summary>向量库一次召回 20-50 条(怕漏),Rerank 用更强的模型逐对精排,挑出真正最相关的 top 3-5 给大模型。召回重、精排准,准确率自然上来。
</details>📚 总结 & 下一节
混合检索 + Rerank 是生产 RAG 标配。下一节:Agent 实战——把 RAG 升级成能"干活"的智能体。
🏋️ 配套自练
- Pinecone RAG 学习中心 — 业界最全的 RAG 教程,从分块到混合检索到 Rerank(免费、英文)
- RAGAS 快速上手 — 装 RAGAS,给你的 RAG 跑一次 faithfulness 评分(免费)
- 把本节的混合检索配置在 Dify 里跑一遍,对比开 Rerank 前后的准确率
🔗 延伸阅读
动手实验
实验改编自 ai-engineer-notebooks(MIT License)第 03 章 Hybrid Retrieval & Reranking(02-hybrid-and-reranking.ipynb)· github.com/calmrocks/ai-engineer-notebooks 运行方式:Colab 或本地 Jupyter 任选;模型用 Groq 免费 API(OpenAI 兼容),换成任何 OpenAI 兼容端点(含公司内部网关)只需改 base_url 与 key。
实验目标
把 BM25 关键词检索与向量检索用 RRF 融合成混合检索,再叠加一层 LLM 重排,并用逐阶段计时数据判断每一层「配不配得上它的成本」。对应本课时讲义「混合检索弥补词表失配」与「rerank 用更贵的精读换更准的相关性排序」。
前置条件
- 免费 GROQ_API_KEY(console.groq.com)。
- Colab 或本地 Jupyter;额外依赖 rank-bm25、sentence-transformers、numpy。
- 安装 aien 包(自带 groq 客户端)。
实验步骤
步骤 1:安装与初始化
%pip install -q "git+https://github.com/calmrocks/ai-engineer-notebooks.git@v0.1.0" requests sentence-transformers numpy rank-bm25
from aien import setup
client, MODEL = setup()
步骤 2:语料、切块与两路检索器
语料下载、清洗、切块、embedding 与上一实验(RAG 基础)完全一致,原 notebook 自包含复制了这些单元格,照搬即可。在此基础上加 BM25:
from rank_bm25 import BM25Okapi
def tokenize(text): # 保留 Retry-After、Sec-WebSocket-Key 这类带符号标识符
return re.findall(r'[a-z0-9][a-z0-9._-]*', text.lower())
bm25 = BM25Okapi([tokenize(t) for t in chunk_texts])
def bm25_search(query, k=5):
scores = bm25.get_scores(tokenize(query))
top = np.argsort(-scores)[:k]
return [(float(scores[i]), int(i)) for i in top]
# vector_search 同上一实验:embedder 编码 query 后与 embeddings 点积取 top-k
步骤 3:两类查询,各有一个检索器赢
q1 = 'when does TCP send a RST segment?' # 精确标识符:rst 是稀有词,BM25 碾压
q2 = 'how can a browser keep a two-way conversation open with a server?' # 换说法:零关键词重叠,向量赢
for q in [q1, q2]:
print(bm25_search(q)[:3])
print(vector_search(q)[:3]) # 对比两个检索器各自的 top-3
预期:q1 上 BM25 直接命中定义 RST 行为的章节,向量检索只捞回「泛 TCP」文本;q2 上 BM25 几乎无词可抓(原文写的是 bidirectional / full-duplex),向量检索轻松落到 WebSocket RFC。生产查询流里两类都存在,所以两路都要。
步骤 4:RRF 融合
def rrf(rankings, k=60, top_n=20):
"""只用排名不用分数:每列贡献 1/(k+rank) 后累加;k=60 是标准常数。"""
scores = {}
for ranking in rankings:
for rank, idx in enumerate(ranking):
scores[idx] = scores.get(idx, 0.0) + 1.0 / (k + rank + 1)
return sorted(scores.items(), key=lambda x: -x[1])[:top_n]
def hybrid_search(query, top_n=20, pool=50):
v = [i for _, i in vector_search(query, pool)]
b = [i for _, i in bm25_search(query, pool)]
return rrf([v, b], top_n=top_n)
为什么不做分数加权平均:BM25 分数无界且依赖语料,余弦分数在 [-1,1],量纲不可比,任何加权和都是带旋钮的谎言;RRF 把分数扔掉只留排名。
步骤 5:LLM 重排与逐阶段计时
import time
def llm_score(query, passage): # 点式重排:一次调用给一个段落打 0-10 分
prompt = ('Rate how relevant this passage is for answering the query, '
'on a scale of 0 (irrelevant) to 10 (directly answers it).\n\n'
f'Query: {query}\n\nPassage:\n{passage[:1500]}\n\n'
'Reply with a single integer 0-10 and nothing else.')
resp = client.chat.completions.create(
model=MODEL, max_tokens=8,
messages=[{'role': 'user', 'content': prompt}])
m = re.search(r'\d+', resp.choices[0].message.content)
return min(int(m.group()), 10) if m else 0
def rerank(query, candidate_ids): # 20 个候选 = 20 次 API 调用
scored = [(llm_score(query, chunk_texts[i]), i) for i in candidate_ids]
return sorted(scored, key=lambda x: -x[0])
q = 'how does a server tell a client to retry later?'
t0 = time.perf_counter(); v = vector_search(q, 50); t1 = time.perf_counter()
b = bm25_search(q, 50); t2 = time.perf_counter()
fused = rrf([[i for _, i in v], [i for _, i in b]], top_n=20); t3 = time.perf_counter()
reranked = rerank(q, [i for i, _ in fused]); t4 = time.perf_counter()
print(f'vector {(t1-t0)*1e3:.1f}ms bm25 {(t2-t1)*1e3:.1f}ms '
f'rrf {(t3-t2)*1e3:.1f}ms rerank {(t4-t3)*1e3:.0f}ms (20 次 API 调用)')
预期形态:BM25 与 RRF 近乎免费(微秒到毫秒级),LLM 重排串行要几十秒。典型结果里重排会把「真正定义行为的章节」提到「只是频繁提到该词」的章节前面——这正是词频和向量距离都看不见的 mention-vs-answer 区别。生产系统会用 cross-encoder(如 Cohere Rerank)把这个开销压低约两个数量级,概念完全相同。
自查问题
- 为什么 BM25 与向量「谁也替代不了谁」?各举一类只有对方能命中的查询。
- reranker 只跑融合后的 top-20 候选而不是全库,为什么?
- 「要不要加 rerank 层」为什么必须靠带已知答案的问题集和命中率来裁决,而不是看两个查询的眼缘?
随堂测
逐题作答,即时反馈
1. 为什么纯向量检索不够,需要加 BM25 全文检索?
2. Rerank 在 RAG 中的核心作用是什么?
3. 在 Dify 中混合检索后效果反而更差,正确的排查思路是?