什么是RAG?

最近会时不时看到或者听到 RAG。搞懂它之前,先弄清大模型真正缺什么——否则你会以为 RAG 是「让模型更聪明的魔法」,而不是「给模型外挂书架」的工程。

一句话:RAG = 先检索外部知识,再增强提示,最后让大模型生成——给 LLM 外挂一个可更新的「知识书架」。

为什么需要 RAG

大模型(LLM)本质是自回归下一词预测:给定上文,猜下一个 token。训练目标是「像不像」,不是「真不真」。

所以它很擅长流畅表达、归纳改写、按格式输出;却很吃力于私有知识、最新信息、精确编号与条款。常见短板:

问题 含义
幻觉 说得一本正经,却没有事实依据
领域知识不足 没见过的内部规章、产品文档答不好
信息过时 知识截止于训练数据时间
上下文不稳 长对话里容易忽略或混淆细节

补救手段不是只有微调。对照一下:

手段 改什么 更适合
提示词 当次上下文 格式、语气、浅规则
RAG 外挂可检索知识 私有文档、常更新资料
工具 / Agent 可执行动作 查库、算数、工作流
微调 模型权重 固定口吻、格式、特定技能

微调像「改脑子」,RAG 像「给书架」。知识常变、还要可追溯依据时,优先 RAG:更新资料不必重训,出错也能追到是哪段文本害的。

什么是 RAG

全称 Retrieval-Augmented Generation。论文脉络可追溯到 Facebook AI / Lewis 等人 2020 年前后:让生成模型访问非参数化的外部记忆(检索库),提升知识密集型任务的事实性。今天工业界说的 RAG,多已扩展成切块、向量库、混合检索、重排、权限、评测与观测一整条流水线。

和搜索对比:传统搜索返回链接,人自己点进去读;RAG 是 搜索 + 阅读理解 + 写答案,并把依据留在上下文里。

两个阶段:离线建库 vs 在线问答

1
2
3
4
5
6
【离线 / 索引】
文档 → 清洗 → 切块 → Embedding → 写入向量库(可加元数据)

【在线 / 问答】
用户问题 →(可选:改写)→ Embedding → Top-K 检索
→(可选:重排)→ 拼进 Prompt → LLM 生成(最好带引用)

检索

从用户问题出发,到知识库、数据库或网页等来源里找出相关片段。现代 RAG 里,「相关」常常靠 向量相似度(语义相近),有时再叠加上 关键词检索(专有名词、编号更稳)。

生成

把检索到的片段当作「开卷考试的参考资料」,和用户问题一起塞进提示词,让 LLM 基于资料作答——而不是仅靠参数里的模糊记忆。

RAG 底层原理(把黑盒拆开)

Embedding:把文字变成可比较的向量

Embedding 模型把任意文本映射到固定维向量,使得 语义相近的文本在几何上靠近。你可以把它想成:把每句话钉到一张很高维的地图上;检索就是在地图上找离问题最近的那些钉子。

关键工程约束:

  1. 文档与查询必须用 同一套 embedding 模型
  2. 换 embedding ≈ 整库重建
  3. embedding 质量上限,基本也是检索质量上限
  4. 短查询、长文档块的向量分布可能不同,块太大时「平均语义」会变糊

还有一个常被忽略的点:相似度高 ≠ 足以回答问题。两段都在谈「宿舍管理」可能很近,但真正含「23:30」的只有一段。所以还要靠切块粒度、重排、以及生成侧的「依据是否足够」来兜住。

选择模型时,除了排行榜分数,最好用自己的 20 个问题做「检索命中」小实验:固定切块,只换 embedding,看 Top-K 命中率谁高。公开榜和你的手册语料不一定同分布。

余弦相似度:检索在算什么

常用指标是余弦相似度:

1
cos(a, b) = (a · b) / (||a|| ||b||)

若向量已 L2 归一化成单位向量,则 cos(a,b) = a · b,可用内积快速排序。向量库(FAISS、Milvus、Chroma、pgvector…)做的就是:在海量向量里快速找近邻。

下面用玩具向量直观感受一下(不是真实 embedding):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import numpy as np

docs = [
"图书馆工作日开放到 22:00。",
"宿舍断电时间是 23:30。",
"退课截止日为开学第三周周五。",
]
index = np.array([
[0.9, 0.1, 0.0],
[0.1, 0.9, 0.0],
[0.0, 0.2, 0.9],
], dtype=float)
index /= np.linalg.norm(index, axis=1, keepdims=True)

def retrieve(q, k=2):
q = q / np.linalg.norm(q)
scores = index @ q
top = np.argsort(-scores)[:k]
return [(docs[i], float(scores[i])) for i in top]

print(retrieve(np.array([0.85, 0.15, 0.0]))) # 更靠近「图书馆」

切块(Chunking):RAG 里杠杆最大的旋钮之一

切块决定「系统能检索到的知识原子」长什么样。业界常说:如果你只打算认真拧一颗螺丝,先拧切块。

策略 说明 适用
固定长度 + overlap 实现简单;注意句边界 快速原型
按标题 / 段落 / Markdown 结构 更贴文档结构 手册、Wiki
语义切块 按语义边界切 效果更好,成本更高
父子块(小块检索、大块回填) 检索用小块,生成带回父段 答案常跨段

经验起点(必须按语料调,没有圣杯参数):块大小常见 256–512 token,overlap 约 10%–20%。

  • 太大:一段混多主题,噪声多
  • 太小:缺上下文,答案割裂
  • 答案跨两块:若单块不足以推出结论,检索再强也可能失败——这是表示层天花板

实操:优先按 ## 标题做结构切分,过长节再二次切;每块带上 source、section 方便引用。

元数据:别把过滤全交给向量

给每块挂上:来源文件、章节、日期/版本、学院或产品线、权限标签。查询时可先 filter 再向量搜,例如「只要 2025 版宿舍条例」。

没有元数据时,过期规章和新规章容易一起被检索到,造成上下文自相矛盾。

查询改写

用户问「几点熄灯」,文档写「断电时间」。可先让模型生成同义问法再检索,或维护一张领域同义词表。项目里一张同义词表往往就很管用。

混合检索与重排(进阶但仍该知道)

纯向量检索对 课程代号、法条编号、产品型号、工单 ID 经常翻车——这些更像「精确匹配」问题;BM25 等关键词检索对此更稳。反之,用户换一种说法提问时,向量又更擅长。

所以生产上常见组合不是二选一,而是流水线:

1
2
3
4
5
向量召回 Top-N  ∪  关键词召回 Top-N
↓
融合(如 RRF:按排名融合,避开不同分数体系不可比)
↓
重排(cross-encoder 等,对短名单精排)→ Top-K 进 Prompt

直觉分工:

阶段 目标 典型规模
召回 别漏掉含答案的块 每路几十条,合并去重
融合 两路结果合成一张候选表 RRF 等
重排 把真正相关的顶上去 从几十压到 5–10
生成 基于精选上下文作答 受窗口与费用约束

注意:cross-encoder 重排 不适合直接扫百万文档;它是第二阶段精排器。有重排时,第一阶段可以取得稍宽,再交给重排「收网」。

先把「纯向量 + 好切块 + 拒答」做到可用;手册里专有名词多,再加关键词路,收益通常立刻可见。

提示词怎么写才像「开卷考试」

坏提示:「根据你的知识回答。」
好提示至少包含:

  1. 只能依据下列资料
  2. 资料不足就明确说不知道 / 资料中没有
  3. 给出引用(文件名、章节、短摘录)
    4.(可选)若资料互相冲突,指出冲突并标明版本日期

RAG 降低幻觉,不 消灭幻觉。拒答能力要靠:提示约束 + 检索分数门槛 + 评测集一起守门。

评测:不要只报一个「端到端准确率」

端到端准确率只能告诉你「坏了」,很少告诉你「哪坏了」。至少拆成:

指标 看什么
检索命中率 Top-K 是否包含应命中段落
回答正确率 最终答案是否可接受
拒答正确率 本该不知道时是否老实
引用正确率 引用是否真指向依据段

改切块或 embedding 时,先看检索命中率有没有涨;否则你可能在用「模型世界知识」冒充 RAG 成功。

离线索引流水线:一步步在干什么

很多人把 RAG 理解成「调一个 similarity_search」。真正决定上限的,往往是 索引怎么建。把离线流水线拆开看:

1. 采集与清洗

来源可能是 PDF、Wiki、Confluence、飞书文档、邮件、工单导出。清洗要干掉:

  • 页眉页脚、水印、目录页的重复噪声
  • 扫描件 OCR 乱码
  • HTML 标签、无意义导航文本
  • 明显重复的段落(同一规章粘贴了三遍)

脏数据进库,后面所有相似度计算都在「脏空间」里做近邻——再贵的模型也救不了。

2. 结构化与切块

手册类优先保留标题层级;表格尽量转成「字段: 值」句子,否则表格被切碎后语义全无。代码/配置片段要避免从中间劈开。切完后抽查 20 个块:人能不能只看这一块大致懂它在说什么?看不懂的块,模型也很难用。

3. Embedding 与入库

选择 embedding 时关注:

  • 是否支持你的语言(中文效果差异可以很大)
  • 维度与价格、延迟
  • 是否与后续重排模型配套
  • 版本钉死:今天 text-embedding-3-small,明天悄悄换别的,旧索引会 silently 变差

入库时务必写入元数据。没有元数据的向量库,像一座没有书架标签的图书馆。

4. 版本与更新

文档更新了怎么办?

  • 全量重建:简单可靠,语料不大时首选
  • 按文档 ID 删除再插入:适合单篇更新
  • 别忘了:改了切块策略 ≈ 旧索引作废

给索引打上 corpus_version / built_at,线上出问题才知道「现在跑的是哪一版书架」。

一个正反对比:同一问题,两种上下文

用户问:「CS301 退课最晚什么时候?」

坏上下文(只靠语义,Top-K 漂了)

模型可能看到的是「宿舍断电」「图书馆开放」之类相近但无关的段,于是:

  • 要么胡编一个日期
  • 要么把「第三周周五」安到错误课程上

根因在检索,不在「GPT 不够聪明」。

好上下文(混合检索抓住课号)

BM25 因 CS301 命中含课号的块,RRF 把它顶上去;模型看到:

计算机学院核心课 CS301《操作系统》退课规则同上……开学第三周周五 17:00

再配合「只能依据资料」的提示,答案才站得住,并能引用原句。

演示时把「仅向量 / 仅 BM25 / 混合」三张榜投屏,比空讲架构图更有说服力。

生成侧还要做的几件事

检索只是把参考书翻开;生成侧仍可能翻车。

上下文组装顺序

常见做法:

  1. 系统规则(角色、拒答、引用格式)放前面,保持稳定
  2. 检索片段按相关性或文档结构排列,并标号 [1][2][3]
  3. 用户问题放在资料附近,避免模型「忘记题目」
  4. 明确要求:先想依据编号,再写答案(有的团队会要求模型先输出引用列表)

片段不要无限堆:宁缺毋滥。5 段高质量上下文,往往好过 20 段噪声。

引用格式示例

1
2
答案:……
依据:[1] campus_notes.md / 选课;[2] …

没有依据编号的「流畅答案」,一律视为不合格——这能逼着链路把检索做实。

多轮对话里的 RAG

第二轮用户说「那周末呢?」时,指代消解很重要:

  • 把指代补全为「图书馆周末开放时间」再检索;或
  • 把上一轮检索到的相关段保留在状态里(短对话)

最怕的是:多轮只追加聊天记录,却不再检索,模型开始用过期的「记忆」编。

RAG 解决了什么,又解决不了什么

适合

  • 企业内部 Wiki / 制度 / 手册问答
  • 课程讲义、论文库、产品文档助手
  • 知识需要经常更新,又不想反复微调
  • 需要可追溯引用的回答

不自动解决

  • 检索错了,生成再强也是「戴着镣铐编故事」
  • 需要多步推理、算数、写库——还要工具 / Agent
  • 权限与安全:检索结果里的注入文本仍可能影响模型
  • 「全书塞进超长上下文」:贵、慢,且注意力会被稀释

和相关概念对比

概念 和 RAG 的关系
微调 可互补:微调管行为,RAG 管知识
Agent 可把「检索」封装成工具,由模型决定何时查
LangChain / LlamaIndex 提供装 RAG 流水线的工程积木
长上下文模型 能多塞一些字,但不能替代优质检索

动手实践

下面按「先数学直觉 → 再最小可运行 RAG → 再对照题集」来。需要 Python 3.10+,以及 OpenAI 兼容 API(也可用本地 embedding + 本地 LLM,自行替换)。

1. 环境准备

1
2
3
4
5
6
7
8
9
10
mkdir -p ~/rag-labs && cd ~/rag-labs
python3 -m venv .venv
source .venv/bin/activate

pip install -U pip
pip install -U langchain langchain-openai langchain-text-splitters
# 做混合检索时再装:
pip install -U rank_bm25 langchain-community
# 若用 EnsembleRetriever:
# pip install -U langchain-classic
1
2
3
export OPENAI_API_KEY="sk-..."
# 若用兼容网关:
# export OPENAI_BASE_URL="https://your-gateway/v1"

2. 准备一份迷你知识库

新建 campus_notes.md:

1
2
3
4
5
6
7
8
9
10
11
12
# 校园手册摘录

【图书馆】
工作日开放时间 08:00–22:00,周末 09:00–21:00。
考研季可能延长开放,以馆内通知为准。

【宿舍】
宿舍周日至周四 23:30 断电,周五周六不断电。
禁止使用违规电器。

【选课】
退课截止日:开学第三周周五 17:00 前在教务系统提交。

3. 最小 RAG(切块 → 向量库 → 生成)

新建 rag_basic.py:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
from pathlib import Path

from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_core.documents import Document
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough
from langchain_core.vectorstores import InMemoryVectorStore
from langchain_text_splitters import RecursiveCharacterTextSplitter

text = Path("campus_notes.md").read_text(encoding="utf-8")
docs = RecursiveCharacterTextSplitter(
chunk_size=200,
chunk_overlap=40,
).create_documents([text])

embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
store = InMemoryVectorStore.from_documents(docs, embeddings)
retriever = store.as_retriever(search_kwargs={"k": 3})

prompt = ChatPromptTemplate.from_template(
"""你只能根据下列资料回答。
如果资料不足以支持结论,请明确说「资料中没有」。
最后一行写:引用摘要。

资料:
{context}

问题:{question}
"""
)

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)


def format_docs(ds: list[Document]) -> str:
return "\n\n".join(d.page_content for d in ds)


rag = (
{
"context": retriever | format_docs,
"question": RunnablePassthrough(),
}
| prompt
| llm
| StrOutputParser()
)

print("=== 应能答对 ===")
print(rag.invoke("宿舍平时几点断电?"))
print("\n=== 应拒答 ===")
print(rag.invoke("食堂有哪些窗口?"))

跑通标准:

  1. 断电问题能答对,且明显依据资料
  2. 食堂问题不应编造菜单

4. 调试口诀:先看检索,再看生成

很多「RAG 不准」其实是检索阶段就错了。加上这段,把 Top-K 打出来:

1
2
3
4
question = "宿舍平时几点断电?"
hits = retriever.invoke(question)
for i, d in enumerate(hits, 1):
print(f"[{i}] {d.page_content[:120]}...")

如果这里片段就不对,优先改:切块、k、embedding、查询改写、元数据过滤——而不是先换更大聊天模型。

5. 可选:相似度门槛拒答

有的向量库能返回分数。思路是:最高分太低就直接拒答,不把噪声塞给模型。阈值要按你的 embedding 与语料标定,没有万能常数。

6. 最小评测集(比「我觉得挺准」有用)

新建 eval_questions.jsonl:

1
2
3
4
{"q": "宿舍几点断电?", "expect_contains": ["23:30"], "type": "answer"}
{"q": "图书馆工作日开到几点?", "expect_contains": ["22:00"], "type": "answer"}
{"q": "食堂有哪些窗口?", "expect_reject": true, "type": "reject"}
{"q": "退课最晚什么时候?", "expect_contains": ["第三周"], "type": "answer"}

跑通时建议带上「应答对 / 应拒答」两类题,比只演示一次快乐路径更有说服力。

7. 常见翻车与排障

现象 优先排查
答非所问 先 print 检索结果;切块与 k
专有名词检索失败 加关键词检索 / 混合检索
偶尔胡编 强化拒答提示;加分数门槛;要引用
换模型后全乱 embedding 是否一起换了?索引是否重建?
又贵又慢 块太大、k 太大、重复建索引、无缓存

排障口诀:先看模型看见了哪些资料,再怀疑模型智商。

8. 混合检索实战(BM25 + 向量 + RRF)

纯向量检索遇到「课程代号 CS301」「错误码 E_AUTH_4031」「宿舍楼 12 栋」这类 精确串 时经常翻车;BM25 关键词检索刚好擅长这个。反过来,用户把「断电」说成「熄灯」时,向量又更稳。

混合检索的目标不是「两路都完美」,而是 用不同偏置互相补洞,再用融合算法合成一张排序表。

先搞懂 RRF:为什么不用直接加分数?

向量相似度一般在 ([-1,1]) 或 ([0,1]) 一带;BM25 分数往往是另一套量纲。两路分数 不能直接相加。

RRF(Reciprocal Rank Fusion,倒数排名融合) 不管原始分数,只看排名:

1
score(d) = Σ_i  weight_i / (k + rank_i(d))
  • rank_i(d):文档 d 在第 i 路检索里的名次(从 1 开始)
  • k:平滑常数,常用 60;k 越大,头部名次的优势越「温和」
  • 同一文档出现在多路结果里,分数会累加——这正是我们想要的「两路都觉得相关」

BM25 到底在干什么?(够用版直觉)

不必推导完整公式,记住三句话即可:

  1. 词频有用,但会边际递减:同一词重复一百次,不等于相关一百倍
  2. 稀有词更值钱:到处都有的「的」「是」权重低;「CS301」这种稀有串权重大
  3. 文档长度要归一:长文档更容易碰巧撞词,需要长度惩罚

所以 BM25 本质是 加权关键词匹配,不是语义理解。它不懂「熄灯≈断电」,但极擅长「字符串对得上」。

向量检索刚好相反:它把文字投到语义空间,近义能靠近,但可能把「CS300」和「CS301」搅在一起。

混合检索 = 让「懂意思的」和「认字的」一起投票。

扩充一点语料,方便看出差异

把 campus_notes.md 改成带「精确编号」的版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 校园手册摘录

【图书馆】
工作日开放时间 08:00–22:00,周末 09:00–21:00。
考研季可能延长开放,以馆内通知为准。
图书借阅系统代号:LIB-SYS-2024。

【宿舍】
宿舍周日至周四 23:30 断电,周五周六不断电。
禁止使用违规电器。
南区 12 栋辅导员电话见学院通知。

【选课】
退课截止日:开学第三周周五 17:00 前在教务系统提交。
计算机学院核心课 CS301《操作系统》退课规则同上。

可运行代码:手写 BM25 + 向量 + RRF

依赖多装两个包:

1
pip install -U rank_bm25 langchain-community

新建 rag_hybrid.py(刻意手写融合,方便看懂原理;不依赖 EnsembleRetriever):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
from __future__ import annotations

from collections import defaultdict
from pathlib import Path
from typing import Iterable

from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_core.documents import Document
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.vectorstores import InMemoryVectorStore
from langchain_text_splitters import RecursiveCharacterTextSplitter
from rank_bm25 import BM25Okapi


def tokenize_zh(text: str) -> list[str]:
"""极简中英混合切词:够 Demo;生产请换 jieba / 更好分词。"""
import re

text = text.lower()
en = re.findall(r"[a-z0-9_\-]+", text)
zh = re.findall(r"[\u4e00-\u9fff]", text)
return en + zh


def rrf_fuse(
ranked_lists: list[list[Document]],
weights: list[float] | None = None,
k: int = 60,
top_n: int = 4,
) -> list[Document]:
weights = weights or [1.0] * len(ranked_lists)
scores: dict[str, float] = defaultdict(float)
keeper: dict[str, Document] = {}

for docs, w in zip(ranked_lists, weights):
for rank, doc in enumerate(docs, start=1):
key = doc.page_content
scores[key] += w / (k + rank)
keeper[key] = doc

ordered = sorted(scores.items(), key=lambda x: x[1], reverse=True)
return [keeper[key] for key, _ in ordered[:top_n]]


# ---------- 建库 ----------
text = Path("campus_notes.md").read_text(encoding="utf-8")
docs = RecursiveCharacterTextSplitter(
chunk_size=180, chunk_overlap=30
).create_documents([text])
for i, d in enumerate(docs):
d.metadata["chunk_id"] = i

embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vector_store = InMemoryVectorStore.from_documents(docs, embeddings)

tokenized_corpus = [tokenize_zh(d.page_content) for d in docs]
bm25 = BM25Okapi(tokenized_corpus)


def dense_search(query: str, k: int = 4) -> list[Document]:
return vector_store.similarity_search(query, k=k)


def bm25_search(query: str, k: int = 4) -> list[Document]:
scores = bm25.get_scores(tokenize_zh(query))
top = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:k]
return [docs[i] for i in top]


def hybrid_search(query: str, k: int = 4) -> list[Document]:
return rrf_fuse(
[dense_search(query, k=k), bm25_search(query, k=k)],
weights=[0.5, 0.5],
k=60,
top_n=k,
)


def show(title: str, hits: Iterable[Document]) -> None:
print(f"\n=== {title} ===")
for i, d in enumerate(hits, 1):
preview = d.page_content.replace("\n", " ")[:80]
print(f"[{i}] {preview}")


# 对比:语义问法 vs 精确代号
q1 = "宿舍晚上大概什么时候会停电?" # 语义,向量通常更强
q2 = "CS301 怎么退课?" # 精确课号,BM25 通常更强

for q in (q1, q2):
print("\n##########", q)
show("仅向量", dense_search(q))
show("仅 BM25", bm25_search(q))
show("混合 RRF", hybrid_search(q))


# ---------- 接到生成 ----------
prompt = ChatPromptTemplate.from_template(
"""只能根据资料回答;不足则说「资料中没有」。最后一行写引用摘要。

资料:
{context}

问题:{question}
"""
)
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
chain = prompt | llm | StrOutputParser()

question = "CS301 退课最晚什么时候?"
ctx = "\n\n".join(d.page_content for d in hybrid_search(question))
print("\n=== 混合检索 + 生成 ===")
print(chain.invoke({"context": ctx, "question": question}))

跑完后建议对照三张榜:

  1. q1 语义问法:向量榜通常更贴「断电」段
  2. q2 带 CS301:BM25 榜更容易抓住含课号的块
  3. 混合榜:两边优点更容易同时留下

这就是混合检索的直观价值:不是平均变强,而是减少「某一路系统性瞎眼」。

用 LangChain EnsembleRetriever(可选)

若你更想要框架封装,也可以:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# pip install -U langchain-community langchain-classic rank_bm25
from langchain_community.retrievers import BM25Retriever
from langchain_classic.retrievers import EnsembleRetriever

bm25_retriever = BM25Retriever.from_documents(docs)
bm25_retriever.k = 4
dense_retriever = vector_store.as_retriever(search_kwargs={"k": 4})

hybrid = EnsembleRetriever(
retrievers=[bm25_retriever, dense_retriever],
weights=[0.5, 0.5],
c=60, # RRF 的 k
)
print(hybrid.invoke("CS301 怎么退课?"))

手写 RRF 比直接调 EnsembleRetriever 更值得先看:别人一问「两路分数怎么融」,公式就在眼前。

混合检索之后还可以接什么?

1
混合召回(宽)→ 去重 →(可选)交叉编码器重排(窄)→ 拼 Prompt → 生成

重排模型(cross-encoder / 云厂商 Rerank API)对「查询+文档」打分更准,但更贵更慢,只适合短名单。第一版可以不上。

RAG 的几种常见形态

形态 做法 何时用
Naive RAG 切块 → 向量 → Top-K → 生成 入门、语料干净
Hybrid RAG 向量 + BM25 + 融合 专有名词/编号多
RAG + 重排 宽召回再精排 要更高准确率
Agentic RAG 模型决定是否检索、是否改写再查 问题类型杂、需多跳
图谱增强 实体关系 + 文本块 强结构知识、复杂关系问答

推荐路径:Naive → Hybrid → 评测过关 → 再考虑 Agent 化。不要第一步就 Multi-Agent RAG。

上线前还要知道的

安全与权限

检索把外部文本送进模型窗口,等于扩大了攻击面:

  1. 提示注入:网页/邮件/工单里写「忽略以上指令…」
  2. 越权读取:A 用户问到了 B 用户文档(没做 ACL 过滤)
  3. 敏感原文外泄:模型把资料原句抄进对不可信用户的回答

防护思路:检索前按身份做元数据过滤;把检索内容当 不可信数据;对外回答脱敏;高风险操作走鉴权与人工审批。

再补两条常被忽略的:官方手册 / 内部制度 / 爬取网页可信度不同,元数据里打 trust_level;索引重建要留审计(谁在何时构建了哪版索引)。

近似近邻:够用的不准

教科书里的相似度是精确两两比较;百万、千万级向量时会用 ANN(HNSW、IVF 等)用近似换速度。原型阶段内存精确搜就够;生产上近似更激进、量化压缩、过滤策略都会偷召回率。「本地 Demo 很好、上线千万文档就变差」——不只是语料变了,检索配置也可能变了。评测时线上线下用同一套检索配置说话。

评测小脚本(示意)

有了 eval_questions.jsonl,可以用非常朴素的脚本做回归:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import json
from pathlib import Path

# 假设你已有 hybrid_search() 与 chain
lines = Path("eval_questions.jsonl").read_text(encoding="utf-8").splitlines()
ok = 0
for line in lines:
item = json.loads(line)
q = item["q"]
ctx_docs = hybrid_search(q)
ctx = "\n\n".join(d.page_content for d in ctx_docs)
ans = chain.invoke({"context": ctx, "question": q})

if item.get("expect_reject"):
passed = ("没有" in ans) or ("不足" in ans) or ("不知道" in ans)
else:
passed = all(x in ans for x in item.get("expect_contains", []))

ok += int(passed)
print(("PASS" if passed else "FAIL"), q, "=>", ans[:60].replace("\n", " "))

print(f"score={ok}/{len(lines)}")

粗糙,但能拦住「改切块改炸了却不知道」。可再加 must_hit_keyword 列,把检索命中率和生成正确率分开看。

从 Demo 到能用

  1. 语料质量:清洗页眉页脚、重复段、扫描噪声、乱码
  2. 切块策略:优先按结构切,保留 overlap,记录元数据
  3. 元数据与权限:该过滤的先过滤,避免越权与过期文档混入
  4. 检索宽度 + 重排:难例再上混合检索与重排
  5. 生成约束:开卷考试提示 + 引用 + 拒答
  6. 评测集:命中率、正确率、拒答率分开看
  7. 更新机制:文档变更后如何增量/全量重建索引
  8. 观测:每次问答记录检索片段与最终答案,方便复盘

建议落地顺序

  1. 划定问答边界 + 准备 15–20 条黄金题(含拒答、含编号题)
  2. 清洗资料,跑通最小向量 RAG
  3. 调切块与 k;强制打印 Top-K
  4. 强化拒答与引用
  5. 上混合检索,对比三张榜,更新评测表
  6. 需要演示时再加 Gradio / Streamlit

若还要「有时检索、有时算数」,把检索封装成工具接到 Agent——见《什么是LangChain?》。

FAQ

Q:上下文窗口都 128K / 1M 了,还要 RAG 吗?
A:要。窗口大不等于该把整库塞进去:贵、慢,而且注意力会被噪声稀释(context rot)。RAG 的价值是「每次只塞相关的一小撮」。超长上下文更适合「已经检索到的中等篇幅材料」,而不是替代检索本身。

Q:RAG 能 100% 消灭幻觉吗?
A:不能。它把考试从闭卷变成开卷;开卷仍可能读错、抄错、脑补。所以要拒答、引用和评测。

Q:一定要向量数据库吗?
A:几十上百页手册,内存向量库足够学习;上生产、要持久化、过滤、海量近邻,再换 Chroma / pgvector / Milvus / Qdrant 等,检索心智类似。

Q:微调是不是更高级?
A:不是替代关系。知识常变用 RAG;口吻/格式要「长在模型身上」再考虑微调。很多系统两者都用。

Q:检索到了但答案仍错?
A:检查:片段是否真进了提示、提示是否允许胡编、Top-K 噪声是否太大、是否需要重排、资料本身是否写错。

Q:混合检索一定比纯向量好吗?
A:不保证。语料全是散文、几乎无编号时,BM25 增益有限,还可能引入噪声。用评测集说话:专有名词题涨了没有?普通语义题掉了没有?

Q:RRF 的 k=60 能改吗?
A:能。k 越小,更偏向各路的第一名;k 越大,排名差异被抹得更平。先用 60,再在你的题集上扫几个值。

Q:中文 BM25 分词很烂怎么办?
A:Demo 用「单字+英文 token」只为跑通;认真做请上 jieba / pkuseg,或走支持中文的搜索引擎(ES 等)当关键词路。

Q:Agentic RAG 和普通 RAG 怎么选?
A:问题类型单一、资料边界清晰 → 普通/混合 RAG 更稳。需要「先判断要不要查、查不够再改写再查、偶尔用计算器」→ 再 Agent 化。

Q:和《什么是LangChain?》怎么分工?
A:本文讲检索增强范式与评测;那边讲编排、工具与 Agent。先懂 RAG,再装框架,不容易变成调包侠。

带走什么

心智模型

  1. LLM 会流畅续写,不保证真实
  2. RAG = 检索 + 增强提示 + 生成;检索错了,生成救不回来
  3. 切块与语料质量,往往比「换更大聊天模型」更值钱
  4. 向量擅长语义,关键词擅长精确匹配;融合用 RRF,别硬加分数
  5. 要拒答、要引用、要分阶段评测;先 Naive/Hybrid 过关,再 Agent 化

典型翻车

  • 答案碰巧对了、依据是假的 → 强制引用 + 检索命中检查
  • 语义近、课号错(CS301/CS300)→ BM25 / 混合检索或元数据过滤
  • 注入与越权 → 来源白名单、ACL、检索文本当不可信数据

常见误区

窗口更大 ≠ 可以不检索;换更大聊天模型救不了检索漂;换 embedding 必须重建索引;Top-K 不是越大越好;「别编造」代替不了拒答评测;不看 Top-K 就调参等于盲调;一上来 Multi-Agent RAG 多半是过早优化。

小结

RAG 不是新模型,而是给 LLM 外挂可更新、可追溯的知识通路:先检索,再生成,用证据约束输出。

资料与切块 → 检索命中(必要时混合)→ 拒答与引用 → 分阶段评测 → 再考虑 Agent / 重排。

调参先看 Top-K,再看最终文案。编号类问题单独测。下一站可接《什么是LangChain?》。

参考文章

配图来源